Repository: astropy/pyvo
Pull request #206 (2020-01-23 03:48:26)

SIAv2
#205 

bsipocz: I'm opening a separate PR to fix the CI.
cbanek: This looks awesome!  I'm going to review this more closely tomorrow, but I like it.  I get what you mentioned about should the searchv2 be on some kind of different object.  I'm not sure about that but I'll think about it.

And I merged the build fix, so you can rebase on top of that and hopefully get your checkmark.
funbaker: A few thoughts:

Why not using inheritance to handle all that duplicate code?
Both service versions differ only in how they present metadata, theres already a `CapabilityMixin` which can be used for sia2. This way one can also distinct between siav1 and v2, making it possible to use just one search function. Two query classes are fine imo, as there are enough differences.

Theres also some code for the DALI Values in `pyvo.dal.adhoc` for which it may make sense to put it into a separate module. This code also handles conversion between different units, which is missing in this PR yet.

It also would make sense to check if the registry code still works correctly for sia2 services.
cbanek: I think I'm with @funbaker , I didn't see that the code was mostly a straight copy from SIAv1 (since that wasn't in the diff), but it seems like just inheriting it might be easier, and it looks like it would get rid of a bunch of code.
andamian: The code is not optimized yet but I'll try to avoid duplication whenever possible.

I was trying to enhance `search` to support v1 and v2, but the biggest problem was the definition of position. In the current API, size denotes a rectangle which in v2 is defined as polygon or range. It has a default of 1.0 so it's hard to tell whether it was ignored or not. One way to do this is to define 3 classes for position:  Circle, Range and Polygon in addition to the current functionality. If the user passed one of the 3, then `size` is ignored.

Another thing to consider is the fact that SIAv2 returns `ObsCore` results. We should probably implement an `ObsCore` module for the results since they can be used with TAP as well? The `ObsCore` software can also be the place to deal with units.

That gets us back to the search function. A common `search` would return results in different format depending on the service it uses. That was another argument it made me lean towards the `search_v2` function.

What do you think?
cbanek: I think it's totally fine to have 2 different search functions, since you really want to be explicit about if you're using v1 or v2 for a search since a given URL might not work with both?

I do like trying to reuse the code for the stuff behind the search with inheritance though.

Interesting point on the ObsCore results, what are you thinking there?  It seems like there's a lot of fields, and most are optional, but I like the idea of wrapping that up a bit, although could you wrap some of the most common ones or the required ones?
funbaker: In that case it'll be still better to have two different service classes than two different methods on the same class.
One service cannot do both versions.

I like the idea of explicitly implementing the DALI Data Types, as there is already some code for it.
They would be needed anyway for a single class solution.

A subclass of `pyvo.dal.query.DALResults` and `pyvo.dal.query.Record` could be used for ObsCoreResults, altough it'd need some more logic if used in TAP (Out of scope for this PR i think).
molinaro-m: SIAP-2.0 states that 

> The response from a successful call to the {query} resource is a table consistent with  ObsTAP responses [...]

(http://www.ivoa.net/documents/SIA/20151223/REC-SIA-2.0-20151223.html#toc34)

In a sense an ObsCoreResults would be nice, but based on its TAP usage, with SIAP-2.0 reusing it
for its scope.

I'm really happy to see SIAv2 moving on in astropy/pyvo!
andamian: @cbanek , @funbaker OK, separate search, separate service class, the SIAQuery class is already different and the SIA record will also be different. Should we just have an `sia2` module that attempts to follow the API and design of `sia` for whomever is already familiar with it but it works with v2? Would that be a good way to transition when v1 is retired?
funbaker: Well, I'm for keeping the interface as simple as possible, hence i spoke out for just one search function.
If the consensus is to make both versions explicit, it should happen on service-class level and not on search-method level, where it'd create ambiguity.

What about a `sia2` module now and a more abstract image service later?
codecov[bot]: # [Codecov](https://codecov.io/gh/astropy/pyvo/pull/206?src=pr&el=h1) Report
> Merging [#206](https://codecov.io/gh/astropy/pyvo/pull/206?src=pr&el=desc) into [master](https://codecov.io/gh/astropy/pyvo/commit/69f5596660447409612dd9b59add424b7634a81a?src=pr&el=desc) will **increase** coverage by `0.02%`.
> The diff coverage is `67.27%`.

[![Impacted file tree graph](https://codecov.io/gh/astropy/pyvo/pull/206/graphs/tree.svg?width=650&token=Mynyo9xoPZ&height=150&src=pr)](https://codecov.io/gh/astropy/pyvo/pull/206?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #206      +/-   ##
==========================================
+ Coverage   70.95%   70.97%   +0.02%     
==========================================
  Files          37       37              
  Lines        3914     3969      +55     
==========================================
+ Hits         2777     2817      +40     
- Misses       1137     1152      +15
```


| [Impacted Files](https://codecov.io/gh/astropy/pyvo/pull/206?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pyvo/dal/sia.py](https://codecov.io/gh/astropy/pyvo/pull/206/diff?src=pr&el=tree#diff-cHl2by9kYWwvc2lhLnB5) | `60.38% <67.27%> (+1.84%)` | :arrow_up: |
| [pyvo/dal/query.py](https://codecov.io/gh/astropy/pyvo/pull/206/diff?src=pr&el=tree#diff-cHl2by9kYWwvcXVlcnkucHk=) | `84.94% <0%> (+0.26%)` | :arrow_up: |
| [pyvo/io/vosi/endpoint.py](https://codecov.io/gh/astropy/pyvo/pull/206/diff?src=pr&el=tree#diff-cHl2by9pby92b3NpL2VuZHBvaW50LnB5) | `85.92% <0%> (+1.48%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/astropy/pyvo/pull/206?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/astropy/pyvo/pull/206?src=pr&el=footer). Last update [69f5596...8bcae51](https://codecov.io/gh/astropy/pyvo/pull/206?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

andamian: @cbanek, @funbaker -Please have a look at the `sia2.py` file before I go too far. This is by no means finished but it gives you an idea of the scope. Some observations:
- Search attributes are explicit. Users should not necessary be familiar with SIA2 in order to use it. I've tried to make the API accommodating and easy to use. For quantities search attributes it accepts any units as long as it can convert them to the standard units required by the spec
- SIAService is now a VOSI service (still need to clean up the old attributes)
- ObsCoreRecord is incomplete and it will be moved elsewhere so that it can be used by other services such as TAP. It provides properties to make it more obvious about the available fields. It also handles quantities. The idea is to avoid referring the user to the standard. Do you see any reason to replace this flat structure with a class structure that more closely follows the ObsCore data model?

Again, it's not ready for review yet, just to give you an idea of the direction and ensure I'm on the right track. Please feel free to comment at your earliest convenience so that I know whether I can continue or change direction. Thanks
funbaker: Apart from a few minor issues i wonder why the properties return a copy.

Also i still think it'd be better to have dedicated classes for the DALI Types, submerged with the existing code. Can also be done later, but it wouldn't need the `append` methods in `sia2.py`, which would make it easier to read.
andamian: > Apart from a few minor issues i wonder why the properties return a copy.
> 
> Also i still think it'd be better to have dedicated classes for the DALI Types, submerged with the existing code. Can also be done later, but it wouldn't need the `append` methods in `sia2.py`, which would make it easier to read.

Properties are lists because they can have multiple values that SIAv2 will OR in the query. One very simple example is `calib_level`. For raw data one might want 0 OR 1 as constraints, hence the list will be [0,1]. It returns a copy of the list so that the user cannot mess with it since it's sync-ed with the formatted version in the params dictionary. `*_append` is used to parse and validate the value before is added to the list and to the dictionary of formatted values.

The alternative was to give users access to the properties list and do the parsing and formatting of the values for the request params later before submitting it. What I didn't like with this approach was the fact that parsing would not fail when a property was set (or added) but much latter.

I'm not 100% satisfied with the API of the `SIAQuery` either but the big advantage of it is that it's an alternative to the `search` function (which is the main, most common entry point of the module) that allows to incrementally collect the constraints. e.g. instantiate a `SIAQuery` object and pass it to different pieces of code that might add constraints to it before executing it.

What DALI types are you thinking about? Can you give an example? If it's a better solution I rather do it now than later.
funbaker: I speak about those DALI Types: http://www.ivoa.net/documents/DALI/20170517/REC-DALI-1.1.html#tth_sEc3

astropy's Quantity's can be used for that, and it should be convertable between votable fields and params (for datalink etc, see also https://github.com/astropy/pyvo/blob/master/pyvo/dal/params.py, this needs some overhaul anyway).
andamian: I see, but I can't just wrap my mind around it, in part because I'm not that familiar with all these standards and how they relate to each other. Are these DALI types used internally only or they are part of the module API? For example, I didn't consider necessary to expose a Point or a Circle or a Polygon in the interface when they can be easily expressed with SkyCoord and Quantities.

I still have to move (or at least split) ObsCoreRecord. I've started with a `dam` package where the ObsCore module will reside. I still have to figure out what goes into it.

I've pushed more changes if you want to have a look (ObsCoreRecord changes not included yet).

funbaker: They are part of the API, both inernally and externally at different places. Having all the data type related code there would make the query api look less bloated, and it'd possible to write `q.pos.append` instead of `q.pos_append`.
The difference to `Quantity` and `Skycoord` is that DALI types have a certain format to be serialized for queries, and a defined number of elements.

There are already several places (sia2, datalink, soda) where those types are used, and I'm sure there will be more in the future, so it's also a question of duplicate code.
msdemlei: On Thu, Feb 06, 2020 at 09:03:08PM -0800, andamian wrote:
> I see, but I can't just wrap my mind around it, in part because I'm
> not that familiar with all these standards and how they relate to
> each other. Are these DALI types used internally only or they are
> part of the module API? For example, I didn't consider necessary to
> expose a Point or a Circle or a Polygon in the interface when they
> can be easily expressed with SkyCoord and Quantities.

DALI is intended as a collection of common conventions for data
access layer specifications, so whatever is there can be expected to
show up in several standards.

For the xtypes, the story is a bit more sinister (or that is how I
would call it).  They're building on what one might call a backdoor
for extending VOTable and define ways to represent geometries,
timestamps, and intervals in VOTables and beyond.

Hence, ideally these extra types (given in the subsections of
http://ivoa.net/documents/DALI/20170517/REC-DALI-1.1.html#tth_sEc3.3)
should, ideally, go into astropy's VOTable support, where they would
presumably be turned into native astropy types where these exist.
And these native astropy types should, IMHO, be used in the
high-level, "pythonic" API to such services.

That's my remote wishlist.  My closer-range wishlist derived from
that would look somewhat like this: let tinkerers feed in strings if
they want in a plumbing-level API, and have a translation layer
turning native astropy objects (or pyvo-local abstractions where
astropy doesn't have anything yet) into these strings, which would be
the default API normal people would use.

andamian: Very valuable comments @funbaker & @msdemlei. 

To get the level of abstraction right, I feel like we need to clarify one thing up front: who do you consider to be the typical user of `pyvo`? Is it someone that it's intimate with VO standards or the regular astronomer and user of `astropy` or `astroquery` packages?

 If it's the former, then it's OK to have other VO abstractions in the API (the `ObsCore` result can be left in its `VOTable` format since the names and types of the columns are standard. Upper layers (`astroquery`) might provide the glue layer to map the `astropy` concepts into `VO` concepts and spare users from needing to know most if not any of the VO parts. If it's the latter, `pyvo` should shield the `VO` concepts as much as possible and talk to users in `astropy`/`astroquery` language.

This will guide us in future developments.

funbaker: I'd say both.

`pyvo` presents the VO concept with the right level of abstraction, while still allowing lower-level work.

`astroquery` does map `VO` concepts indeed, but it doesn't work with the `VO` only.

If those DALI Objects are constructed in a way which allow tuples of `Quantity` and `SkyCoord` as well, this shouldn't be a problem.

This is already done e.g. here https://github.com/astropy/pyvo/blob/master/pyvo/dal/adhoc.py#L705
cbanek: Just giving this another look now and I'm confused - it looks like there's an sia2.py but the names are the same as sia.py. Seems like this could be confusing with the same names.  If this is part of the "it's not ready yet" then disregard :smile: .
andamian: Now I see what you mean @funbaker. I'll try to rework it so that that code in Soda and other places is not duplicated.
Question: Since the mapping from tuples to positional constructs is pretty straightforward (3 member tuple->Circle, 4 member tuple->Range, >4 member tuple -> Polygon), do we really need 3 types of arguments or a generic `pos` such as the one that I've been using in `sia2` is sufficient? 
andamian: @cbanek - that was on purpose. We are using the same design and same constructs so that for someone that is already familiar with the `sia` module is easier to transition to the `sia2`. But I appreciate your point too.

funbaker: @andamian One `pos` argument is enough i think, if one needs to bypass it there's dict access.
andamian: @funbaker pushed more changes. Created an `AxisParamMixin` class to be used by both `SIAQuery` and `SodaQuery`. Please have a look and let me know if you are OK with this approach. Both SIAv2 and SODA allow for multiple values for the constraints, hence the need for the `*_append` and `*_del` methods.
I've made `ObsCore` a stand alone class in a new `dam` package. `ObsCoreRecord` in SIA2 will subclass and populate the inherited `ObsCore` attributes. What do you think?
funbaker: @andamian `AxisParamMixing` sounds good, altough I'm not sure about the position and naming, but i got no strong opinion about that.
The same applies to `ObsCore`.

About `*_append` and `*_del`: i still think this should move into a subclass of list, similar to the `HomogeneousList` in astropy.
msdemlei: On Wed, Feb 12, 2020 at 03:06:29AM -0800, andamian wrote:
> @funbaker pushed more changes. Created an `AxisParamMixin` class to
> be used by both `SIAQuery` and `SodaQuery`. Please have a look and
> let me know if you are OK with this approach. Both SIAv2 and SODA
> allow for multiple values for the constraints, hence the need for
> the `*_append` and `*_del` methods.

Looking at the amount of code I feel my concerns about having
repeated parameters reconfirmed -- it seems a lot of effort for some
IMHO rather peripheral feature.  Ah well.  It's in the standard now,
so we have to cope.

What I wonder is if the API would become a lot more unwieldy for the
users if we just told people to append to the lists themselves (i.e.,
not returning copies from the properties).  That certainly would save
a lot of code -- and would it make the API so much more fragile?
Yes, an exception would probably only be raised at query time rather
than when people put in bad parameters -- but then people who do this
kind of incremental query building probably know well enough what
they do to be able to still figure the source of the bug out.


I've got another concern here : I think most people in the VO now
feel that the polymorphous parameter POS in SIAv2 was a mistake, and
it only made it into SODA because some insisted on being able to
propagate SIAv2 queries to SODA.

So, at least as long as there's no native astropy scircles and
spolys, I'd say the pyvo API should expose the CIRCLE and POLYGON
parameters (from SODA), both constructable with (at least)
appropriate sequences of floats and appropriate strings containing
blank-separated floats.  

The SIA2 class would turn that into appropriate POS, either (my
preference) at query time or (if you want timely excpetions) at
addition time, SODA would use them without translation.

POS itself, on the other hand, should IMHO let in arbitrary strings.
This might become handy when, say, services start supporting MOCs in
there and clients want to bypass your sanitation logic.

> I've made `ObsCore` a stand alone class in a new `dam` package.
> `ObsCoreRecord` in SIA2 will subclass and populate the inherited
> `ObsCore` attributes. What do you think?

I'm all for having an explicit model for obscore result rows, and
SIAv2 results should use that.  I wouldn't call the class ObsCore,
though, because what's inside isn't an ObsCore.  Perhaps it's an
ObsCoreRow, but, frankly, I'd probably just call it Observation, as
that's what it's supposed to model.

If we go for "Observation", one would need to think about what
happens if (or rather when) pyVO grows support for CAOMn.  But I'm
pretty sure (though I haven't checked) there's a straightforward
mapping from CAOMn to obscore; so, that could then be an extension to
our current Observation.  Which therefore perhaps could be
BasicObservation?

Or, since I'm not at such lengths, perhaps we should be honest and
call it ObsCoreMetadata?  I guess my vote would be on that.

       -- Markus

andamian: @funbaker and @msdemlei - I've pushed another version and this one looks much better IMO. This new implementation allows users to manipulate the attribute sets themselves but the values are parsed and validated as they are added. I think it's more obvious now and besides I've managed to eliminate a lot of code duplication.

I thought about the Circle, Range and Polygon classes but I couldn't see a clear gain that users will get by adopting them. `pos.add(Circle((1, 2, 3))` is no better than `pos.add((1, 2,3))` and `pos.add(Circle(ra=1, dec=2, radius=3))` is just marginally better by being more explicit. Maybe if `Circle` includes more functionality, then yes (parse from VOTable method, format to DAL parameters, etc.?). `Point` seems to have more potential as it can be used in both query parameters and results (`ObsCoreMetadata`). `Point` can also be used with `Circle` and `Polygon`. But then what is the relationship between `Point` and `SkyCoord`? I've looked at possible synergies with `Circle` and `Polygon` classes in `pyvo.dal.params` but they seem to be used for result parsing and I didn't want to break something by adopting them. I'm not an astronomer so I cannot comment on which approach feels more natural. However, if opinions converge on this, I'm more than happy to oblige.

If there are no more major objections with this approach, I will try to tide it up tomorrow (add documentation and incorporate @msdemlei comments) and invite a larger audience to comment. If you have higher level comments please let me know now at your earliest convenience. When everyone is happy with high level I suggest will move to the more traditional line by line review.

Thanks again.
andamian: Changes are in as promised and I think we are ready for a line by line review.. Some notes for someone looking at the code just now:
1. There is the question whether to use dedicated position classes (Point, Circle, Polygon, Range) instead of the tuples. Please indicate your preference.
2. As I mention before, I think that the API should hide the details of the spec whenever possible and use constructs that the community at large is familiar with. Please let me know where I failed or could do better.
2. I've tried to present the user with a consistent view. That means that I've taken the liberty to change some of the names in the `ObsCoreRecord`. Attribute corresponding to `pol_states` is simple `pol` so that if the constraint `pol` is used in the query the corresponding result attribute is also `pol`. For others I've used a shorter version of the name when possible. Please let me know if any doesn't make sense.
3. obs_publisher_did seemed a bit hard to understand by general public so I call it `global_id` (it could also be `universal_id`). If this is a bad idea and in fact the publisher ID makes sense to the general community I will use a different name.
msdemlei: On Thu, Feb 13, 2020 at 10:59:58PM -0800, andamian wrote:
> Changes are in as promised and I think we are ready for a line by
> line review.. Some notes for someone looking at the code just now:

Not sure when I'll have time to squeeze this in, so I'll be grateful
to anyone else having a look.  But:

> 3. obs_publisher_did seemed a bit hard to understand by general
> public so I call it `global_id` (it could also be `universal_id`).
> If this is a bad idea and in fact the publisher ID makes sense to
> the general community I will use a different name.

Uh, no.  There's a second identifier in there that should also be
globally unique, obs_creator_did.  The distinction is simply that the
creator DID would be assigned by whoever produces the dataset and it
would go with it as different publishers publish it.  

While I've not seen too many of them in the wild -- try

```
select top 30 * 
from ivoa.obscore 
where obs_collection='TMAP'
```

on http://dc.g-vo.org/tap if you want to see some --, they're still
there and would arguably better deserve the title "global id", as,
where given, it would actually identify a dataset as such, rather
than "something published here".  For instance, once creators
actually assign them, you could use this to weed out duplicates
from different datacenters that mirror data collections.

So, I'd argue it should be clear from the attribute name that
obs_publisher_did is an identifier assigned by the publisher.  I give
you the "obs_" at the start isn't pretty, but frankly I'd rather not
abstract obscore names away without a strong reason, because after
all I'd expect many users would write queries against these columns
and thus would be familiar with their names.

If you still absolutely detest the name obs_publisher_did (for which
I would have some sympathy if it weren't part of the "obscore UI"), a
more helpful name might be publisher_assigned_id, or perhaps even
publisher_assigned_globally_unique_id.  But I don't think anyone
would want to type that latter thing.

funbaker: Made some comment - not requested changes at this point, because i'll do another iteration anyway and I'm not 100% sure if all points are valid.
andamian: @msdemlei - sold. Thank you for the nice explanation, some of which I've tried to capture in the docs. I've re-named it `publisher_did` for now to be consistent with existing `creator_did` and that's mainly because `obs_` seems a bit redundant, but we could follow the `ObsCore` if that's more appropriate.
funbaker: Well, LGTM when CI is green (unless someone else has something to say).
andamian: I have no idea why the tests are failing on Travis. The data directory and the capabilities file are there. Any idea?

funbaker: @andamian probably because the test data is not defined in `pyvo/dal/tests/setup_package.py`
andamian: Yeah - thanks. Figured it out eventually. Confusing because it works fine locally.

What do you think should we do about the outstanding issues: `pos` types: tuples or proper `Circle`, `Range` or `Polygon` classes and the attribute names in `ObsCoreMetadata`? I'd like to have an agreement on these things.
funbaker: I'd say proper types, with those tuples to be passes into the constructor, so that they can be magically autoconstructed when a tuple is passed. I did a similar thing in the other interfaces.
andamian: By automagically you mean something like:
```
if instanceof(pos, tuple):
   pos = Circle(ra=pos[0], dec=pos[1], radius=pos[2])
elif instanceof(pos, Circle):
  pass
else:
   raise Error
```?
Or is there a better/smarter way? And these classes are available in the `pyvo.dal` package or somewhere higher?

andamian: I'm really stuck with the sphinx error. Don't even know what where to look.
funbaker: @andamian 
> By automagically you mean something like:

Something like this, yep.

> And these classes are available in the `pyvo.dal` package or somewhere higher?

There's no dedicated classes, just some code in `pyvo.dal.params` which I wrote for datalink/soda.

About sphinx: Looks like a warning about a docstring which is converted into an error.
andamian: > @andamian
> 
> > By automagically you mean something like:
> 
> Something like this, yep.
> 
> > And these classes are available in the `pyvo.dal` package or somewhere higher?
> 
> There's no dedicated classes, just some code in `pyvo.dal.params` which I wrote for datalink/soda.

Those classes don't deal with Quantities, Units and don't validate the inputs. I'll need to move the validation code that I have for `pos` there.
> 
> About sphinx: Looks like a warning about a docstring which is converted into an error.

I know. Just can't figure out exactly which docstring it's referring to.

andamian: @funbaker - FYI, for using the Circle and Polygon classes, I will need to expand their usage. Instantiating one of these classes might result in `ValueError` (as opposed to `DALServiceError`). Calling code might turn them into `DALServiceError` or other error types depending on the context.
cbanek: I took a look at your sphinx issue and debugged it.  Looks like it was an indentation issue.  This is kind of cute to not repeat the long docstring, but it's really confusing to me.  In the other place where you use it, the indentation is different.

Looking at the docs themselves, we are only showing the doc for the search function, we aren't including any other functions, as they would have the same names as the normal SIA classes... 

Here's a gist that I cooked up that doesn't error, but it doesn't render properly either, since the rendering of what is a parameter or if it's the text of the previous one is the indentation level:
https://gist.github.com/cbanek/a1fb603fccc62be4f54f4f3211ce469e

My simple suggestion would just be to copy the comment block and try not to do some string manipulation on it when it's imported, then the indentation will be really much more obvious.
andamian: @funbaker & @cbanek - thank you for the code reviews and approvals. However, I am not comfortable to go ahead with the merge and release, at least not until I get more feedback on the `ObsCoreMetadata` attributes. Support for proper positional types can be added later but the `ObsCoreMetadata` attributes are hard to change back after they get out in the wild. The very least I can do is use the ObsCore names, but then the gain is very marginal since those names are already accessible as column names in the tabular representation. Ideas?
andamian: @funbaker & @cbanek pushed another commit. Attributes in `ObsCoreMetadata` match the names in `ObsCore`. I've also fixed the tests so no we can run `remote-data` ones. After your blessing, can we merge and possibly release?

I have another work in progress in `astroquery` this time. I've created a `vo` module that implements their API using `pyvo` goodies only and is dependent on these changes.
msdemlei: On Thu, Feb 20, 2020 at 02:41:35PM -0800, andamian wrote:
> @funbaker & @cbanek pushed another commit. Attributes in
> `ObsCoreMetadata` match the names in `ObsCore`. I've also fixed the
> tests so no we can run `remote-data` ones. After your blessing, can
> we merge and possibly release?

Well, I personally could have done without the obs_, too, but it's
probably better not to mess with names people might know from
ivoa.obscore.  So, you absolutely have my blessing for
ObsCoreMetadata.

Just so I can later say "told you so", I'll repeat my reservations
about the design decision to expose the POS argument that I believe
is a misfeature of SIAv2; I'd have implemented circle and polygon as
in SODA.  In my practice, polymorphic geometries have been a lot more
pain than they've been gain.

But from the fact that I can't be bothered to make a PR to introduce
circle and polygon arguments you can probably guess that I'm not
convinced myself that pyvo is the place to fix SIAv2.  And as long as
we don't know how astropy will eventually model such geometries (or
do we?), pos probably wins on the side of being more supple when
adapting to whatever astropy might be throwing at us.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 683bd78..7d93e2a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,6 +7,8 @@
 
 - Properly handle single string keywords value for regsearch(). [#201]
 
+- Add support for SIAv2. [#206]
+
 1.0 (2019-09-20)
 ================
 
diff --git a/pyvo/dal/__init__.py b/pyvo/dal/__init__.py
index c8262ed..25624a6 100644
--- a/pyvo/dal/__init__.py
+++ b/pyvo/dal/__init__.py
@@ -1,5 +1,6 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 from .sia import search as imagesearch
+from .sia2 import search as imagesearch2
 from .ssa import search as spectrumsearch
 from .sla import search as linesearch
 from .scs import search as conesearch
@@ -13,13 +14,14 @@ from .sla import SLAService, SLAQuery, SLAResults, SLARecord
 from .scs import SCSService, SCSQuery, SCSResults, SCSRecord
 from .tap import TAPService, TAPQuery, TAPResults, AsyncTAPJob
 
+
 from .exceptions import (
     DALAccessError, DALProtocolError, DALFormatError, DALServiceError,
     DALQueryError)
 
 __all__ = [
     "imagesearch", "spectrumsearch", "linesearch", "conesearch", "tablesearch",
-    "DALService",
+    "DALService", "sia2", "imagesearch2",
     "SIAService", "SSAService", "SLAService", "SCSService", "TAPService",
     "DALQuery",  "SIAQuery", "SSAQuery", "SLAQuery", "SCSQuery", "TAPQuery",
     "DALResults",
diff --git a/pyvo/dal/adhoc.py b/pyvo/dal/adhoc.py
index 8ab69de..5d0ecf7 100644
--- a/pyvo/dal/adhoc.py
+++ b/pyvo/dal/adhoc.py
@@ -3,6 +3,7 @@
 Datalink classes and mixins
 """
 import numpy as np
+import warnings
 
 from .query import DALResults, DALQuery, DALService, Record
 from .exceptions import DALServiceError
@@ -10,6 +11,7 @@ from .vosi import AvailabilityMixin, CapabilityMixin
 from .params import find_param_by_keyword, get_converter
 
 from astropy.io.votable.tree import Param
+from astropy import units as u
 from astropy.units import Quantity, Unit
 from astropy.units import spectral as spectral_equivalencies
 
@@ -17,6 +19,9 @@ from astropy.io.votable.tree import Resource, Group
 from astropy.utils.collections import HomogeneousList
 
 from ..utils.decorators import stream_decode_content
+from .params import PosQueryParam, IntervalQueryParam, TimeQueryParam,\
+    EnumQueryParam
+from ..dam.obscore import POLARIZATION_STATES
 
 
 # monkeypatch astropy with group support in RESOURCE
@@ -672,7 +677,42 @@ class DatalinkRecord(DatalinkRecordMixin, SodaRecordMixin, Record):
         return list(_get_input_params_from_resource(proc_resource).values())
 
 
-class SodaQuery(DatalinkQuery):
+class AxisParamMixin():
+    """
+    Stores the axis parameters (pos, band, time and pol) used in SODA
+    or SIAv2 queries
+    """
+    @property
+    def pos(self):
+        if not hasattr(self, '_pos'):
+            self._pos = PosQueryParam()
+            self['POS'] = self._pos.dal
+        return self._pos
+
+    @property
+    def band(self):
+        if not hasattr(self, '_band'):
+            self._band = IntervalQueryParam(unit=u.meter,
+                                            equivalencies=u.spectral())
+            self['BAND'] = self.band.dal
+        return self._band
+
+    @property
+    def time(self):
+        if not hasattr(self, '_time'):
+            self._time = TimeQueryParam()
+            self['TIME'] = self.time.dal
+        return self._time
+
+    @property
+    def pol(self):
+        if not hasattr(self, '_pol'):
+            self._pol = EnumQueryParam(POLARIZATION_STATES)
+            self['POL'] = self.pol.dal
+        return self._pol
+
+
+class SodaQuery(DatalinkQuery, AxisParamMixin):
     """
     a class for preparing a query to a SODA Service.
     """
@@ -703,25 +743,15 @@ class SodaQuery(DatalinkQuery):
 
     @circle.setter
     def circle(self, circle):
+        if len(circle) != 3:
+            raise ValueError(
+                "Range must be a sequence with exactly three values")
+        self['CIRCLE'] = PosQueryParam().get_dal_format(circle).\
+            replace('CIRCLE ', '')
         setattr(self, '_circle', circle)
         del self.range
         del self.polygon
 
-        if not isinstance(circle, Quantity):
-            valerr = ValueError(
-                "Circle must be a sequence with exactly three values")
-
-            try:
-                # assume degrees
-                circle = circle * Unit('deg')
-                if len(circle) != 3:
-                    raise valerr
-            except (ValueError, TypeError):
-                raise valerr
-
-        self['CIRCLE'] = ' '.join(
-            str(value) for value in circle.to(Unit('deg')).value)
-
     @circle.deleter
     def circle(self):
         if hasattr(self, '_circle'):
@@ -734,31 +764,25 @@ class SodaQuery(DatalinkQuery):
         """
         A rectangular range.
         """
+        warnings.warn(
+            "Use pos attribute instead",
+            DeprecationWarning
+        )
         return getattr(self, '_circle', None)
 
     @range.setter
     def range(self, range):
+        if len(range) != 4:
+            raise ValueError(
+                "Range must be a sequence with exactly four values")
+        self['POS'] = PosQueryParam().get_dal_format(range)
         setattr(self, '_range', range)
         del self.circle
         del self.polygon
 
-        if not isinstance(range, Quantity):
-            valerr = ValueError(
-                "Range must be a sequence with exactly four values")
-
-            try:
-                # assume degrees
-                range = range * Unit('deg')
-                if len(range) != 4:
-                    raise valerr
-            except (ValueError, TypeError):
-                raise valerr
-
-        self['POS'] = 'RANGE ' + ' '.join(
-            str(value) for value in range.to(Unit('deg')).value)
-
     @range.deleter
     def range(self):
+
         if hasattr(self, '_range'):
             delattr(self, '_range')
         if 'POS' in self and self['POS'].startswith('RANGE'):
@@ -774,27 +798,15 @@ class SodaQuery(DatalinkQuery):
 
     @polygon.setter
     def polygon(self, polygon):
+        if len(polygon) < 6:
+            raise ValueError(
+                "Polygon must be a sequence of at least six values")
+        self['POLYGON'] = PosQueryParam().get_dal_format(polygon).\
+            replace('POLYGON ', '')
         setattr(self, '_polygon', polygon)
         del self.circle
         del self.range
 
-        if not isinstance(polygon, Quantity):
-            valerr = ValueError(
-                'Polygon must be a sequence with at least six numeric values, '
-                'expressing pairs of ra and dec in degrees'
-            )
-
-            try:
-                # assume degrees
-                polygon = polygon * Unit('deg')
-                if len(polygon) < 3:
-                    raise valerr
-            except (ValueError, TypeError):
-                raise valerr
-
-        self['POLYGON'] = ' '.join(
-            str(value) for value in polygon.to(Unit('deg')).value)
-
     @polygon.deleter
     def polygon(self):
         if hasattr(self, '_polygon'):
diff --git a/pyvo/dal/params.py b/pyvo/dal/params.py
index d4ffe05..42f0a3f 100644
--- a/pyvo/dal/params.py
+++ b/pyvo/dal/params.py
@@ -3,7 +3,10 @@
 This file contains functionallity related to VOTABLE Params.
 """
 import numpy as np
+from collections import MutableSet
+import abc
 
+from astropy import units as u
 from astropy.units import Quantity, Unit
 from astropy.time import Time
 from astropy.io.votable.converters import (
@@ -206,3 +209,242 @@ class Polygon(Number):
             raise DALServiceError('Size is not a multiple of 3')
 
         return super().serialize(value)
+
+
+class AbstractDalQueryParam(MutableSet, metaclass=abc.ABCMeta):
+    """
+    Base class for a DAL parameter. In general, a DAL parameter allows
+    for multiple values which are OR-ed by the service. As such, the class
+    behaves like a set that stores all the values.
+
+    When a value is added to an attribute, it is validated and formatted
+    to conform to the using service (SIAv2 or SODA) and value errors might be
+    raised. The `dal` attribute stores the current list of formatted
+    attributes.
+
+    Subclasses must override the `dal_formatter` method that formats values
+    for serialization. That includes unit conversions and string representation
+    Duplicates in the set are determine based on the formatted DAL
+    representation of the value.
+    """
+    def __init__(self, values=()):
+        """
+        Parameters
+        ----------
+        values : sequence, optional
+            An initial set of values.
+        """
+        self.dal = []
+        self._data = []
+        for item in values:
+            self.add(item)
+        super().__init__()
+
+    @abc.abstractmethod
+    def get_dal_format(self, item):
+        """
+        Method to be provided by subclasses
+        """
+        return
+
+    def add(self, item):
+        if item in self:
+            return
+        self._data.append(item)
+        self.dal.append(self.get_dal_format(item))
+
+    def discard(self, item):
+        # relies on the fact that both the raw and the formatted
+        # attribute lists have the items in the same order. It
+        # uses the formatted list (normalized units) to get the index.
+        index = self.dal.index(self.get_dal_format(item))
+        self._data.pop(index)
+        self.dal.pop(index)
+
+    def __iter__(self):
+        return iter(self._data)
+
+    def __len__(self):
+        return len(self._data)
+
+    def __contains__(self, item):
+        # check dal format for duplications since the quantities are known
+        return self.get_dal_format(item) in self.dal
+
+
+class StrQueryParam(AbstractDalQueryParam):
+    """
+    Representation of a unitless, single-value parameter. The formatter is
+    just a str() cast
+    """
+    def get_dal_format(self, val):
+        return str(val)
+
+
+class PosQueryParam(AbstractDalQueryParam):
+    """
+    Representation of a position parameter. Depending on the number
+    of entries, the resulting DAL format is CIRCLE, RANGE or POLYGON.
+    """
+    def get_dal_format(self, val):
+        """
+        formats the tuple values into a string to be sent to the service
+        entries in values are either quantities or assumed to be degrees
+        """
+        self._validate_pos(val)
+        if len(val) == 3:
+            shape = 'CIRCLE'
+        elif len(val) == 4:
+            shape = 'RANGE'
+        elif len(val) > 5 and not len(val) % 2:
+            shape = 'POLYGON'
+        else:
+            raise ValueError(
+                'Invalid shape {}. Tuple with 3 (CIRCLE), 4 (RANGE) or '
+                'even 6 and above (POLYGON) accepted.'.format(val))
+        return '{} {}'.format(shape, ' '.join(
+            [str(val.to(u.deg).value) if isinstance(val, Quantity) else
+             str((val*u.deg).value) for val in val]))
+
+    def _validate_pos(self, pos):
+        """
+        validates position
+
+        This has probably done already somewhere else
+        """
+        if len(pos) == 3:
+            self._validate_ra(pos[0])
+            self._validate_dec(pos[1])
+            if not isinstance(pos[2], Quantity):
+                radius = pos[2] * u.deg
+            else:
+                radius = pos[2]
+            if radius <= 0*u.deg or radius.to(u.deg) > 90*u.deg:
+                raise ValueError('Invalid circle radius: {}'.format(radius))
+        elif len(pos) == 4:
+            ra_min = pos[0] if isinstance(pos[0], Quantity) else pos[0] * u.deg
+            ra_max = pos[1] if isinstance(pos[1], Quantity) else pos[1] * u.deg
+            dec_min = pos[2] if isinstance(pos[2], Quantity) \
+                else pos[2] * u.deg
+            dec_max = pos[3] if isinstance(pos[3], Quantity) \
+                else pos[3] * u.deg
+            self._validate_ra(ra_min)
+            self._validate_ra(ra_max)
+            if ra_max.to(u.deg) < ra_min.to(u.deg):
+                raise ValueError('min > max in ra range: '.format(ra_min,
+                                                                  ra_max))
+            self._validate_dec(dec_min)
+            self._validate_dec(dec_max)
+            if dec_max.to(u.deg) < dec_min.to(u.deg):
+                raise ValueError('min > max in dec range: '.format(dec_min,
+                                                                   dec_max))
+        else:
+            for i, m in enumerate(pos):
+                if i % 2:
+                    self._validate_dec(m)
+                else:
+                    self._validate_ra(m)
+
+    def _validate_ra(self, ra):
+        if not isinstance(ra, Quantity):
+            ra = ra * u.deg
+        if ra.to(u.deg).value < 0 or ra.to(u.deg).value > 360.0:
+            raise ValueError('Invalid ra: {}'.format(ra))
+
+    def _validate_dec(self, dec):
+        if not isinstance(dec, Quantity):
+            dec = dec * u.deg
+        if dec.to(u.deg).value < -90.0 or dec.to(u.deg).value > 90.0:
+            raise ValueError('Invalid dec: {}'.format(dec))
+
+
+class IntervalQueryParam(AbstractDalQueryParam):
+    """
+    Representation of an interval DAL parameter.
+    """
+    def __init__(self, unit=None, equivalencies=None):
+        """
+        Parameters
+        ----------
+        unit : `astropy.unit`
+            Unit this paramter is represented in DAL format
+        equivalencies: list
+            List of equivalencies for unit conversion
+        """
+        self._unit = unit
+        self._equivalencies = equivalencies
+        super().__init__()
+
+    def get_dal_format(self, val):
+        if isinstance(val, tuple):
+            if len(val) == 1:
+                high = low = val[0]
+            elif len(val) == 2:
+                low = val[0]
+                high = val[1]
+            else:
+                raise ValueError('Too few/many values in interval attribute: '.
+                                 format(val))
+        else:
+            high = low = val
+        if self._unit:
+            if not isinstance(low, Quantity):
+                low = low*self._unit
+            low = low.to(self._unit, equivalencies=self._equivalencies).value
+            if not isinstance(high, Quantity):
+                high = high*self._unit
+            high = high.to(self._unit, equivalencies=self._equivalencies).value
+        if low > high:
+            raise ValueError('Invalid interval: min({}) > max({})'.format(
+                low, high))
+
+        return '{} {}'.format(low, high)
+
+
+class TimeQueryParam(AbstractDalQueryParam):
+    """
+    Representation of a timestamp parameter.
+    """
+    def get_dal_format(self, val):
+        if isinstance(val, tuple):
+            if len(val) == 1:
+                max_time = min_time = val[0]
+            elif len(val) == 2:
+                min_time = val[0]
+                max_time = val[1]
+            else:
+                raise ValueError('Too few/many members in time attribute: '.
+                                 format(val))
+        else:
+            max_time = min_time = val
+
+        if not isinstance(min_time, Time):
+            min_time = Time(min_time)
+        if not isinstance(max_time, Time):
+            max_time = Time(max_time)
+        if min_time > max_time:
+            raise ValueError('Invalid time interval: min({}) > max({})'.format(
+                min_time, max_time
+            ))
+        return '{} {}'.format(min_time.mjd, max_time.mjd)
+
+
+class EnumQueryParam(AbstractDalQueryParam):
+    """
+    Representation of an enum parameter
+    """
+    def __init__(self, allowed_values):
+        """
+        Parameters
+        ----------
+        allowed_values : sequence
+            Sequence of allowed values for the enum
+        """
+        self._allowed = allowed_values
+        super().__init__()
+
+    def get_dal_format(self, val):
+        if val not in self._allowed:
+            raise ValueError('{} not a valid value from: {}'.
+                             format(val, self._allowed))
+        return str(val)
diff --git a/pyvo/dal/query.py b/pyvo/dal/query.py
index 0ce777d..0c684cf 100644
--- a/pyvo/dal/query.py
+++ b/pyvo/dal/query.py
@@ -205,7 +205,7 @@ class DALQuery(dict):
         url = self.queryurl
         params = {k: v for k, v in self.items()}
 
-        response = self._session.get(url, params=params, stream=True)
+        response = self._session.get(url, params=params, stream=True, allow_redirects=True)
         return response
 
     def execute_votable(self):
diff --git a/pyvo/dal/sia2.py b/pyvo/dal/sia2.py
new file mode 100644
index 0000000..1f6a0b4
--- /dev/null
+++ b/pyvo/dal/sia2.py
@@ -0,0 +1,1072 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+A module for searching for images in a remote archive.
+
+A Simple Image Access (SIA) service allows a client to search for
+images based on a number of criteria/parameters. The results are
+represented in `pyvo.dam.obscore.ObsCoreMetadata` format.
+
+The ``SIAService`` class can represent a specific service available at a URL
+endpoint.
+"""
+
+from astropy import units as u
+from astropy import time
+
+from .query import DALResults, DALQuery, DALService, Record
+from .adhoc import DatalinkResultsMixin, AxisParamMixin, SodaRecordMixin,\
+    DatalinkRecordMixin
+from .params import IntervalQueryParam, StrQueryParam, EnumQueryParam
+from .vosi import AvailabilityMixin, CapabilityMixin
+from ..dam import ObsCoreMetadata, CALIBRATION_LEVELS
+
+
+__all__ = ["search", "SIAService", "SIAQuery", "SIAResults", "ObsCoreRecord"]
+
+SIA2_STANDARD_ID = 'ivo://ivoa.net/std/SIA#query-2.0'
+
+
+SIA_PARAMETERS_DESC = """
+pos : single or list of tuples
+    angle units (default: deg)
+    the positional region(s) to be searched for data. Each region can
+    be expressed as a tuple representing a CIRCLE, RANGE or POLYGON as
+    follows:
+    (ra, dec, radius) - for CIRCLE. (angle units - defaults to)
+    (long1, long2, lat1, lat2) - for RANGE (angle units required)
+    (ra, dec, ra, dec, ra, dec ... ) ra/dec points for POLYGON all
+    in angle units
+band : scalar, tuple(interval) or list of tuples
+    (spectral units (default: meter)
+    the energy interval(s) to be searched for data.
+time : single or list of `~astropy.time.Time` or compatible strings
+    the time interval(s) to be searched for data.
+pol : single or list of str from `pyvo.dam.obscore.POLARIZATION_STATES`
+    the polarization state(s) to be searched for data.
+field_of_view : single or list of tuples
+    angle units (default arcsec)
+    the range(s) of field of view (size) to be searched for data
+spatial_resolution : single or list of tuples
+    angle units required
+    the range(s) of spatial resolution to be searched for data
+spectral_resolving_power : single or list of tuples
+    the range(s) of spectral resolving power to be searched for data
+exptime : single or list of tuples
+    time units (default: second)
+    the range(s) of exposure times to be searched for data
+timeres : single of list of tuples
+    time units (default: second)
+    the range(s) of temporal resolution to be searched for data
+publisher_did : single or list of str
+    specifies the unique identifier of dataset(s). It is global because
+    it must include information regarding the publisher
+    (obs_publisher_did in ObsCore)
+collection : single or list of str
+    name of the collection that the data belongs to
+facility : single or list of str
+    specifies the name of the facility (usually telescope) where
+    the data was acquired.
+instrument : single or list of str
+    specifies the name of the instrument with which the data was
+    acquired.
+data_type : 'image'|'cube'
+    specifies the type of the data
+calib_level : single or list from enum
+    `pyvo.dam.obscore.CALIBRATION_LEVELS`
+    specifies the calibration level of the data. Can be a single value
+    or a list of values
+target_name : single or list of str
+    specifies the name of the target (e.g. the intention of the
+    original science program or observation)
+res_format : single or list of strings
+    specifies response format(s).
+max_records : int
+    allows the client to limit the number or records in the response
+"""
+
+
+def search(url, pos=None, band=None, time=None, pol=None,
+           field_of_view=None, spatial_resolution=None,
+           spectral_resolving_power=None, exptime=None,
+           timeres=None, publisher_did=None, facility=None, collection=None,
+           instrument=None, data_type=None, calib_level=None,
+           target_name=None, res_format=None, maxrec=None, session=None):
+    """
+    submit a simple SIA query to a SIAv2 compatible service
+
+    Parameters
+    ----------
+
+    url : str
+       url of the SIA service (base or endpoint)
+    _SIA2_PARAMETERS
+
+    """
+    service = SIAService(url)
+    return service.search(pos=pos, band=band, time=time, pol=pol,
+                          field_of_view=field_of_view,
+                          spatial_resolution=spatial_resolution,
+                          spectral_resolving_power=spectral_resolving_power,
+                          exptime=exptime, timeres=timeres,
+                          publisher_did=publisher_did,
+                          facility=facility, collection=collection,
+                          instrument=instrument, data_type=data_type,
+                          calib_level=calib_level, target_name=target_name,
+                          res_format=res_format, maxrec=maxrec,
+                          session=session)
+
+
+search.__doc__ = search.__doc__.replace('_SIA2_PARAMETERS',
+                                        SIA_PARAMETERS_DESC)
+
+
+def _tolist(value):
+    # return value as a list - is there something in Python to do that?
+    if not value:
+        return []
+    if isinstance(value, list):
+        return value
+    return [value]
+
+
+class SIAService(DALService, AvailabilityMixin, CapabilityMixin):
+    """
+    a representation of an SIA2 service
+    """
+
+    def __init__(self, baseurl, session=None):
+        """
+        instantiate an SIA service
+
+        Parameters
+        ----------
+        url : str
+           url - URL of the SIA service (base or query endpoint)
+        session : object
+           optional session to use for network requests
+        """
+
+        super().__init__(baseurl, session=session)
+
+        # Check if the session has an update_from_capabilities attribute.
+        # This means that the session is aware of IVOA capabilities,
+        # and can use this information in processing network requests.
+        # One such usecase for this is auth.
+        if hasattr(self._session, 'update_from_capabilities'):
+            self._session.update_from_capabilities(self.capabilities)
+
+        self.query_ep = None  # service query end point
+        for cap in self.capabilities:
+            # assumes that the access URL is the same regardless of the
+            # authentication method except BasicAA which is not supported
+            # in pyvo. So pick any access url as long as it's not
+            if cap.standardid.lower() == SIA2_STANDARD_ID.lower():
+                for interface in cap.interfaces:
+                    if interface.accessurls and not \
+                        [m for m in interface.securitymethods if
+                         m.standardid != 'ivo://ivoa.net/sso#BasicAA']:
+                        self.query_ep = interface.accessurls[0].content
+                        break
+
+    def search(self, pos=None, band=None, time=None, pol=None,
+               field_of_view=None, spatial_resolution=None,
+               spectral_resolving_power=None, exptime=None,
+               timeres=None, publisher_did=None, facility=None, collection=None,
+               instrument=None, data_type=None, calib_level=None,
+               target_name=None, res_format=None, maxrec=None, session=None):
+        """
+        Performs a SIAv2 search against a SIAv2 service
+
+        See Also
+        --------
+        pyvo.dal.sia2.SIAQuery
+
+        """
+        return SIAQuery(self.query_ep, pos=pos, band=band,
+                        time=time, pol=pol,
+                        field_of_view=field_of_view,
+                        spatial_resolution=spatial_resolution,
+                        spectral_resolving_power=spectral_resolving_power,
+                        exptime=exptime, timeres=timeres,
+                        publisher_did=publisher_did,
+                        facility=facility, collection=collection,
+                        instrument=instrument, data_type=data_type,
+                        calib_level=calib_level, target_name=target_name,
+                        res_format=res_format, maxrec=maxrec,
+                        session=session).execute()
+
+
+class SIAQuery(DALQuery, AxisParamMixin):
+    """
+    a class very similar to :py:attr:`~pyvo.dal.query.SIAQuery` class but
+    used to interact with SIAv2 services.
+    """
+
+    def __init__(self, url, pos=None, band=None, time=None, pol=None,
+                 field_of_view=None, spatial_resolution=None,
+                 spectral_resolving_power=None, exptime=None,
+                 timeres=None, publisher_did=None,
+                 facility=None, collection=None,
+                 instrument=None, data_type=None, calib_level=None,
+                 target_name=None, res_format=None, maxrec=None,
+                 session=None):
+        """
+        initialize the query object with a url and the given parameters
+
+        Note: The majority of the attributes represent constraints used to
+        query the SIA service and are represented through lists. Multiple value
+        attributes are OR-ed in the query, however the values of different
+        attributes are AND-ed. Intervals are represented with tuples and
+        open-ended intervals should be expressed with float("-inf") or
+        float("inf"). Eg. For all values less than or equal to 600 use
+        (float(-inf), 600)
+
+        Additional attribute constraints can be specified (or removed) after
+        this object has been created using the *.add and *_del methods.
+
+        Parameters
+        ----------
+        url : url where to send the query request to
+        _SIA2_PARAMETERS
+        session : object
+           optional session to use for network requests
+
+        Returns
+        -------
+        SIAResults
+            a container holding a table of matching image records. Records are
+            represented in IVOA ObsCore format
+
+        Raises
+        ------
+        DALServiceError
+            for errors connecting to or communicating with the service
+        DALQueryError
+            if the service responds with an error,
+            including a query syntax error.
+
+        See Also
+        --------
+        SIAResults
+        pyvo.dal.query.DALServiceError
+        pyvo.dal.query.DALQueryError
+
+        """
+        super().__init__(url, session=session)
+
+        for pp in _tolist(pos):
+            self.pos.add(pp)
+
+        for bb in _tolist(band):
+            self.band.add(bb)
+
+        for tt in _tolist(time):
+            self.time.add(tt)
+
+        for pp in _tolist(pol):
+            self.pol.add(pp)
+
+        for ff in _tolist(field_of_view):
+            self.field_of_view.add(ff)
+
+        for sp in _tolist(spatial_resolution):
+            self.spatial_resolution.add(sp)
+
+        for sr in _tolist(spectral_resolving_power):
+            self.spectral_resolving_power.add(sr)
+
+        for et in _tolist(exptime):
+            self.exptime.add(et)
+
+        for tr in _tolist(timeres):
+            self.timeres.add(tr)
+
+        for ii in _tolist(publisher_did):
+            self.publisher_did.add(ii)
+
+        for ff in _tolist(facility):
+            self.facility.add(ff)
+
+        for col in _tolist(collection):
+            self.collection.add(col)
+
+        for inst in _tolist(instrument):
+            self.instrument.add(inst)
+
+        for dt in _tolist(data_type):
+            self.data_type.add(dt)
+
+        for cal in _tolist(calib_level):
+            self.calib_level.add(cal)
+
+        for tt in _tolist(target_name):
+            self.target_name.add(tt)
+
+        for rf in _tolist(res_format):
+            self.res_format.add(rf)
+
+        self.maxrec = maxrec
+
+    __init__.__doc__ = \
+        __init__.__doc__.replace('_SIA2_PARAMETERS', SIA_PARAMETERS_DESC)
+
+    @property
+    def field_of_view(self):
+        if not hasattr(self, '_fov'):
+            self._fov = IntervalQueryParam(u.deg)
+            self['FOV'] = self._fov.dal
+        return self._fov
+
+    @property
+    def spatial_resolution(self):
+        if not hasattr(self, '_spatres'):
+            self._spatres = IntervalQueryParam(u.arcsec)
+            self['SPATRES'] = self._spatres.dal
+        return self._spatres
+
+    @property
+    def spectral_resolving_power(self):
+        if not hasattr(self, '_specrp'):
+            self._specrp = IntervalQueryParam()
+            self['SPECRP'] = self._specrp.dal
+        return self._specrp
+
+    @property
+    def exptime(self):
+        if not hasattr(self, '_exptime'):
+            self._exptime = IntervalQueryParam(u.second)
+            self['EXPTIME'] = self._exptime.dal
+        return self._exptime
+
+    @property
+    def timeres(self):
+        if not hasattr(self, '_timeres'):
+            self._timeres = IntervalQueryParam(u.second)
+            self['TIMERES'] = self._timeres.dal
+        return self._timeres
+
+    @property
+    def publisher_did(self):
+        if not hasattr(self, '_publisher_did'):
+            self._publisher_did = StrQueryParam()
+            self['ID'] = self._publisher_did.dal
+        return self._publisher_did
+
+    @property
+    def facility(self):
+        if not hasattr(self, '_facility'):
+            self._facility = StrQueryParam()
+            self['FACILITY'] = self._facility.dal
+        return self._facility
+
+    @property
+    def collection(self):
+        if not hasattr(self, '_collection'):
+            self._collection = StrQueryParam()
+            self['COLLECTION'] = self._collection.dal
+        return self._collection
+
+    @property
+    def instrument(self):
+        if not hasattr(self, '_instrument'):
+            self._instrument = StrQueryParam()
+            self['INSTRUMENT'] = self._instrument.dal
+        return self._instrument
+
+    @property
+    def data_type(self):
+        if not hasattr(self, '_data_type'):
+            self._data_type = StrQueryParam()
+            self['DPTYPE'] = self._data_type.dal
+        return self._data_type
+
+    @property
+    def calib_level(self):
+        if not hasattr(self, '_cal'):
+            self._cal = EnumQueryParam(CALIBRATION_LEVELS)
+            self['CALIB'] = self._cal.dal
+        return self._cal
+
+    @property
+    def target_name(self):
+        if not hasattr(self, '_target'):
+            self._target_name = StrQueryParam()
+            self['TARGET'] = self._target_name.dal
+        return self._target_name
+
+    @property
+    def res_format(self):
+        if not hasattr(self, '_res_format'):
+            self._res_format = StrQueryParam()
+            self['FORMAT'] = self._res_format.dal
+        return self._res_format
+
+    @property
+    def maxrec(self):
+        return self._maxrec
+
+    @maxrec.setter
+    def maxrec(self, val):
+        if not val:
+            return
+        if not isinstance(val, int) and val > 0:
+            raise ValueError('maxrec {} must be positive int'.format(val))
+        self._maxrec = val
+        self['MAXREC'] = str(val)
+
+    def execute(self):
+        """
+        submit the query and return the results as a SIAResults instance
+
+        Raises
+        ------
+        DALServiceError
+           for errors connecting to or communicating with the service
+        DALQueryError
+           for errors either in the input query syntax or
+           other user errors detected by the service
+        DALFormatError
+           for errors parsing the VOTable response
+        """
+        return SIAResults(self.execute_votable(), url=self.queryurl, session=self._session)
+
+
+class SIAResults(DatalinkResultsMixin, DALResults):
+    """
+    The list of matching images resulting from an image (SIA) query.
+    Each record contains a set of metadata that describes an available
+    image matching the query constraints.  The number of records in
+    the results is available via the :py:attr:`nrecs` attribute or by
+    passing it to the Python built-in ``len()`` function.
+
+    This class supports iterable semantics; thus,
+    individual records (in the form of
+    :py:class:`~pyvo.dal.sia2.ObsCoreRecord` instances) are typically
+    accessed by iterating over an ``SIAResults`` instance.
+
+    >>> results = pyvo.imagesearch(url, pos=[12.24, -13.1], size=0.1)
+    >>> for image in results:
+    ...     print("{0}: {1}".format(image.title, title.getdataurl()))
+
+    Alternatively, records can be accessed randomly via
+    :py:meth:`getrecord` or through a Python Database API (v2)
+    Cursor (via :py:meth:`~pyvo.dal.query.DALResults.cursor`).
+    Column-based data access is possible via the
+    :py:meth:`~pyvo.dal.query.DALResults.getcolumn` method.
+
+    ``SIAResults`` is essentially a wrapper around an Astropy
+    :py:mod:`~astropy.io.votable`
+    :py:class:`~astropy.io.votable.tree.Table` instance where the
+    columns contain the various metadata describing the images.
+    One can access that VOTable directly via the
+    :py:attr:`~pyvo.dal.query.DALResults.votable` attribute.  Thus,
+    when one retrieves a whole column via
+    :py:meth:`~pyvo.dal.query.DALResults.getcolumn`, the result is
+    a Numpy array.  Alternatively, one can manipulate the results
+    as an Astropy :py:class:`~astropy.table.table.Table` via the
+    following conversion:
+
+    >>> table = results.votable.to_table()
+
+    ``SIAResults`` supports the array item operator ``[...]`` in a
+    read-only context.  When the argument is numerical, the result
+    is an
+    :py:class:`~pyvo.dal.sia2.ObsCoreRecord` instance, representing the
+    record at the position given by the numerical index.  If the
+    argument is a string, it is interpreted as the name of a column,
+    and the data from the column matching that name is returned as
+    a Numpy array.
+    """
+
+    def getrecord(self, index):
+        """
+        return a representation of a sia result record that follows
+        dictionary semantics. The keys of the dictionary are those returned by
+        this instance's fieldnames attribute. The returned record has
+        additional image-specific properties
+
+        Parameters
+        ----------
+        index : int
+           the integer index of the desired record where 0 returns the first
+           record
+
+        Returns
+        -------
+        ObsCoreMetadataRecord
+           a dictionary-like wrapper containing the result record metadata.
+
+        Raises
+        ------
+        IndexError
+           if index is negative or equal or larger than the number of rows in
+           the result table.
+
+        See Also
+        --------
+        Record
+        """
+        return ObsCoreRecord(self, index, session=self._session)
+
+
+class ObsCoreRecord(SodaRecordMixin, DatalinkRecordMixin, Record,
+                    ObsCoreMetadata):
+    """
+    a dictionary-like container for data in a record from the results of an
+    image (SIAv2) search, describing an available image in ObsCore format.
+
+    The commonly accessed metadata which are stadardized by the SIA
+    protocol are available as attributes.  If the metadatum accessible
+    via an attribute is not available, the value of that attribute
+    will be None.  All metadata, including non-standard metadata, are also
+    acessible via the ``get(`` *key* ``)`` function (or the [*key*]
+    operator) where *key* is table column name.
+    """
+
+    #          OBSERVATION INFO
+    @property
+    def dataproduct_type(self):
+        """
+        Data product (file content) primary type. This is coded as a string
+        that conveys a general idea of the content and organization of a
+        dataset.
+        """
+        return self.get('dataproduct_type', decode=True)
+
+    @property
+    def dataproduct_subtype(self):
+        """
+        Data product more specific type
+        """
+        return self.get('dataproduct_subtype', decode=True, default=None)
+
+    @property
+    def calib_level(self):
+        """
+        Calibration level of the observation: in {0, 1, 2, 3, 4}
+        """
+        return self.get('calib_level')
+
+    #          TARGET INFO
+    @property
+    def target_name(self):
+        """
+        The target_name attribute contains the name of the target of the
+        observation, if any. This is typically the name of an astronomical
+        object, but could be the name of a survey field.
+        The target name is most useful for output, to identify the target of
+        an observation to the user. In queries it is generally better to refer
+        to astronomical objects by position, using a name resolver to convert
+        the target name into a coordinate (when possible).
+        """
+        return self.get('target_name', decode=True)
+
+    @property
+    def target_class(self):
+        """
+        This field indicates the type of object that was pointed for this
+        observation. It is a string with possible values defined in a special
+        vocabulary set to be defined: list of object classes (or types) used
+        by the SIMBAD database, NED or defined in another IVOA vocabulary.
+        """
+        return self.get('target_class', decode=True, default=None)
+
+    #          DATA DESCRIPTION
+    @property
+    def obs_id(self):
+        """
+        Collection specific internal ID given by the ObsTAP service
+        """
+        return self.get('obs_id', decode=True)
+
+    @property
+    def obs_title(self):
+        """
+        Brief description of dataset in free format
+        """
+        return self.get('obs_title', decode=True, default=None)
+
+    @property
+    def obs_collection(self):
+        """
+        The name of the collection (DataID.Collection) identifies the data
+        collection to which the data product belongs. A data collection can be
+        any collection of datasets which are alike in some fashion. Typical
+        data collections might be all the data from a particular telescope,
+        instrument, or survey. The value is either the registered shortname
+        for the data collection, the full registered IVOA identifier for the
+        collection, or a data provider defined short name for the collection.
+        Examples: HST/WFPC2, VLT/FORS2, CHANDRA/ACIS-S, etc.
+        """
+        return self.get('obs_collection', decode=True)
+
+    @property
+    def obs_create_date(self):
+        """
+        Date when the dataset was created
+        """
+        cd = self.get('obs_create_date', default=None)
+        return cd if not cd else time.Time(cd)
+
+    @property
+    def obs_creator_name(self):
+        """
+        The name of the institution or entity which created the dataset.
+        """
+        return self.get('obs_creator_name', decode=True, default=None)
+
+    @property
+    def obs_creator_did(self):
+        """
+        IVOA dataset identifier given by its creator.
+        """
+        return self.get('obs_creator_did', decode=True, default=None)
+
+    #         CURATION INFORMATION
+    @property
+    def obs_release_date(self):
+        """
+        Observation release date
+        """
+        rt = self.get('obs_release_date', default=None, decode=True)
+        return rt if not rt else time.Time(rt)
+
+    @property
+    def obs_publisher_did(self):
+        """
+        ID for the Dataset assigned by the publisher. Note that data from
+        a source (creator_did) can be published by multiple publishers
+        and have assigned multiple publisher data IDs.
+        """
+        return self.get('obs_publisher_did', decode=True)
+
+    @property
+    def publisher_id(self):
+        """
+        IVOA-ID for the Publisher. It will also be globally unique since each
+        publisher has a unique registered publisher ID
+        """
+        return self.get('publisher_id', decode=True, default=None)
+
+    @property
+    def bib_reference(self):
+        """
+        URL or bibcode for documentation. This is a forward link to major
+        publications which reference the dataset.
+        """
+        return self.get('bib_reference', decode=True, default=None)
+
+    @property
+    def data_rights(self):
+        """
+        This parameter allows mentioning the availability of a dataset.
+        Possible values are: public, secure, or proprietary.
+        """
+        return self.get('data_rights', decode=True, default=None)
+
+    #           ACCESS INFORMATION
+    @property
+    def access_url(self):
+        """
+        The access_url column contains a URL that can be used to download the
+        data product (as a file of some sort). Access URLs are not guaranteed
+        to remain valid and unchanged indefinitely. To access a specific data
+        product after a period of time (e.g., days or weeks) a query should be
+        performed to obtain a fresh access URL.
+        """
+        return self.get('access_url', decode=True)
+
+    @property
+    def access_format(self):
+        """
+        Content format of the dataset. The value of access_format should be a
+        MIME type, either a standard MIME type, an extended MIME type from
+        the above table, or a new custom MIME type defined by the data
+        provider.
+        """
+        return self.get('access_format', decode=True)
+
+    @property
+    def access_estsize(self):
+        """
+        The approximate size (in kilobytes) of the file available via the
+        access_url. This is used only to gain some idea of the size of a data
+        product before downloading it, hence only an approximate value is
+        required. Provision of dataset size estimates is important whenever it
+        is possible that datasets can be very large.
+        """
+        return self.get('access_estsize')*1000*u.byte
+
+    #           SPATIAL CHARACTERISATION
+    @property
+    def s_ra(self):
+        """
+        ICRS Right Ascension of the center of the observation
+        """
+        return self.get('s_ra')*u.deg
+
+    @property
+    def s_dec(self):
+        """
+        CRS Declination of the center of the observation
+        """
+        return self.get('s_dec')*u.deg
+
+    @property
+    def s_fov(self):
+        """
+        Approximate size of the covered region as the diameter of a containing
+        circle. For most data products the value given should be large enough
+        to include the entire area of the observation; coverage within the
+        bounded region need not be complete, for example if the specified
+        radius encompasses a rotated rectangular region. For observations
+        which do not have a well-defined boundary, e.g. radio or
+        high energy observations, a characteristic value should be given.
+        The radius attribute provides a simple way to characterize and use
+        (e.g. for discovery computations) the approximate spatial coverage of a
+        data product. The spatial coverage of a data product can be more
+        precisely specified using the region attribute.
+        """
+        return self.get('s_fov')*u.deg
+
+    @property
+    def s_region(self):
+        """
+        Sky region covered by the data product (expressed in ICRS frame).
+        It can be used to precisely specify the covered spatial region of a
+        data product.
+        It is often an exact, or almost exact, representation of the
+        illumination region of a given observation defined in a standard way
+        by the concept of Support in the Characterisation data model.
+        """
+        return self.get('s_region', decode=True)
+
+    @property
+    def s_resolution(self):
+        """
+        Spatial resolution of data specifies a reference value chosen by the
+        data provider for the estimated spatial resolution of the data product
+        in arcseconds. This refers to the smallest spatial feature in the
+        observed signal that can be resolved.
+        In cases where the spatial resolution varies across the field the best
+        spatial resolution (smallest resolvable spatial feature) should be
+        specified. In cases where the spatial frequency sampling of an
+        observation is complex (e.g., interferometry) a typical value for
+        spatial resolution estimate should be given; additional
+        characterisation may be necessary to fully specify the spatial
+        characteristics of the data.
+        """
+        return self.get('s_resolution')*u.arcsec
+
+    @property
+    def s_xel1(self):
+        """
+        Number of elements along the first spatial axis
+        """
+        return self.get('s_xel1')
+
+    @property
+    def s_xel2(self):
+        """
+        Number of elements along the second spatial axis
+        """
+        return self.get('s_xel2')
+
+    @property
+    def s_ucd(self):
+        """
+        UCD for the nature of the spatial axis (pos or u,v data)
+        """
+        return self.get('s_ucd', decode=True, default=None)
+
+    @property
+    def s_unit(self):
+        """
+        Unit used for spatial axis
+        """
+        return self.get('s_unit', decode=True, default=None)
+
+    @property
+    def s_resolution_min(self):
+        """
+        Resolution min value on spatial axis (FHWM of PSF)
+        """
+        rmin = self.get('s_resolution_min', default=None)
+        return rmin if not rmin else rmin*u.arcsec
+
+    @property
+    def s_resolution_max(self):
+        """
+        Resolution max value on spatial axis (FHWM of PSF)
+        """
+        rmax = self.get('s_resolution_max', default=None)
+        return rmax if not rmax else rmax * u.arcsec
+
+    @property
+    def s_calib_status(self):
+        """
+        A string to encode the calibration status along the spatial axis
+        (astrometry). Possible values could be {uncalibrated, raw, calibrated}
+        """
+        return self.get('s_calib_status', decode=True, default=None)
+
+    @property
+    def s_stat_error(self):
+        """
+        This parameter gives an estimate of the astrometric statistical error
+        after the astrometric calibration phase.
+        """
+        return self.get('s_stat_error', decode=True, default=None)
+
+    @property
+    def s_pixel_scale(self):
+        """
+        This corresponds to the sampling precision of the data along the
+        spatial axis. It is stored as a real number corresponding to the
+        spatial sampling period, i.e., the distance in world coordinates
+        system units between two pixel centers. It may contain two values if
+        the pixels are rectangular.
+        """
+        return self.get('s_pixel_scale', decode=True, default=None)
+
+    #           TIME CHARACTERISATION
+    @property
+    def t_xel(self):
+        """
+        Number of elements along the time axis
+        """
+        return self.get('t_xel')
+
+    @property
+    def t_ref_pos(self):
+        """
+        Time Axis Reference Position as defined in STC REC, Section 4.4.1.1.1
+        """
+        return self.get('t_ref_pos', decode=True, default=None)
+
+    @property
+    def t_min(self):
+        """
+        The start time of the observation specified in MJD. In case of data
+        products result of the combination of multiple frames, min time must
+        be the minimum of the start times
+        """
+        return time.Time(self.get('t_min'), format='mjd')
+
+    @property
+    def t_max(self):
+        """
+        The stop time of the observation specified in MJD. In case of data
+        products result of the combination of multiple frames, t_max must
+        be the maximum of the stop times
+        """
+        return time.Time(self.get('t_min'), format='mjd')
+
+    @property
+    def t_exptime(self):
+        """
+        Total exposure time. For simple exposures, this is just the time_bounds
+         size expressed in seconds. For data where the detector is not active
+         at all times (e.g. data products made by combining exposures taken at
+         different times), the t_exptime will be smaller than the time_bounds
+         interval. For data where the xptime is not constant over the entire
+         data product, the median exposure time per pixel is a good way to
+         characterize the typical value. In some cases, exptime is generally
+         used as an indicator of the relative sensitivity (depth) within a
+         single data collection (e.g. obs_collection); data providers should
+         supply a suitable relative value when it is not feasible to define or
+         compute the true exposure time.
+
+        In case of targeted observations, on the contrary the exposure time is
+        often adjusted to achieve similar signal to noise ratio for different
+        targets.
+        """
+        return self.get('t_exptime')*u.second
+
+    @property
+    def t_resolution(self):
+        """
+        Estimated or average value of the temporal resolution.
+        """
+        return self.get('t_resolution')*u.second
+
+    @property
+    def t_calib_status(self):
+        """
+        Type of time coordinate calibration. Possible values are principally
+        {uncalibrated, calibrated, raw, relative}. This may be extended for
+        specific time domain collections.
+        """
+        return self.get('t_calib_status', decode=True, default=None)
+
+    @property
+    def t_stat_error(self):
+        """
+        Time coord statistical error on the time measurements in seconds
+        """
+        ter = self.get('t_stat_error', default=None)
+        return ter if not ter else ter*u.second
+
+    #           SPECTRAL CHARACTERISATION
+    @property
+    def em_xel(self):
+        """
+        Number of elements along the spectral axis
+        """
+        return self.get('em_xel')
+
+    @property
+    def em_ucd(self):
+        """
+        Nature of the spectral axis
+        """
+        return self.get('em_ucd', decode=True, default=None)
+
+    @property
+    def em_unit(self):
+        """
+        Units along the spectral axis
+        """
+        return self.get('em_unit', decode=True, default=None)
+
+    @property
+    def em_calib_status(self):
+        """
+        This attribute of the spectral axis indicates the status of the data
+        in terms of spectral calibration. Possible values are defined in the
+        Characterisation Data Model and belong to {uncalibrated , calibrated,
+        relative, absolute}.
+        """
+        return self.get('em_calib_status', decode=True, default=None)
+
+    @property
+    def em_min(self):
+        """
+        Minimum of the spectral interval covered by the observation
+        """
+        return self.get('em_min')*u.meter
+
+    @property
+    def em_max(self):
+        """
+        Maximum of the spectral interval covered by the observation
+        """
+        return self.get('em_max')*u.meter
+
+    @property
+    def em_res_power(self):
+        """
+        Average estimation for the spectral resolution power stored as a
+        double value, with no unit.
+        """
+        return self.get("em_res_power")
+
+    @property
+    def em_res_power_min(self):
+        """
+        Resolving power min value on spectral axis
+        """
+        return self.get('em_res_power_min', None)
+
+    @property
+    def em_res_power_max(self):
+        """
+        Resolving power max value on spectral axis
+        """
+        return self.get('em_res_power_max', None)
+
+    @property
+    def em_resolution(self):
+        """
+        A mean estimate of the resolution, e.g. Full Width at Half Maximum
+        (FWHM) of the Line Spread Function (or LSF). This can be used for
+        narrow range spectra whereas in the majority of cases, the resolution
+        power is preferable due to the LSF variation along the spectral axis.
+        """
+        if 'em_resolution' in self.keys():
+            return self.get('em_resolution')*u.meter
+        return None
+
+    @property
+    def em_stat_error(self):
+        """
+        Spectral coord statistical error (accuracy along the spectral axis)
+        """
+        if 'em_stat_error' in self.keys():
+            return self.get('em_stat_error')*u.meter
+        return None
+
+    #           OBSERVABLE AXIS
+    @property
+    def o_ucd(self):
+        """
+        Nature of the observable axis within the data product
+        """
+        return self.get('o_ucd', decode=True)
+
+    @property
+    def o_unit(self):
+        """
+        Units along the observable axis
+        """
+        return self.get('o_unit', decode=True, default=None)
+
+    @property
+    def o_calib_status(self):
+        """
+        Type of calibration applied on the Flux observed (or other observable
+        quantity).
+        """
+        return self.get('o_calib_status', decode=True, default=None)
+
+    @property
+    def o_stat_error(self):
+        """
+        Statistical error on the Observable axis.
+        Note: the return value has the units defined in unit
+        """
+        return self.get('o_stat_error', decode=True, default=None)
+
+    #           POLARIZATION CHARACTERISATION
+    @property
+    def pol_xel(self):
+        """
+        Number of different polarization states present in the data. The
+        default value is 0, indicating that polarization was not explicitly
+        observed. Corresponding values are stored in the `pol` property
+        """
+        return self.get('pol_xel')
+
+    @property
+    def pol_states(self):
+        """
+        List of polarization states present in the data file. Possible values
+        are: {I Q U V RR LL RL LR XX YY XY YX POLI POLA}. Values in the
+        set are separated by the '/' character. A leading / character must
+        start the list and a trailing / character must end it. It should be
+        ordered following the above list, compatible with the FITS list table
+        for polarization definition.
+        """
+        return self.get('pol_states', decode=True, default=None)
+
+    #           PROVENANCE
+    @property
+    def instrument_name(self):
+        """
+        The name of the instrument used for the acquisition of the data
+        """
+        return self.get('instrument_name', decode=True)
+
+    @property
+    def facility_name(self):
+        """
+        Name of the facility or observatory used to collect the data
+        """
+        return self.get('facility_name', decode=True, default=None)
+
+    @property
+    def proposal_id(self):
+        """
+        Identifier of proposal to which observation belongs
+        """
+        return self.get('proposal_id', default=None, decode=True)
diff --git a/pyvo/dal/vosi.py b/pyvo/dal/vosi.py
index 9ede6af..b116cf1 100644
--- a/pyvo/dal/vosi.py
+++ b/pyvo/dal/vosi.py
@@ -17,7 +17,34 @@ __all__ = [
     'AvailabilityMixin', 'CapabilityMixin', 'VOSITables']
 
 
-class AvailabilityMixin:
+class EndpointMixin():
+    def _get_endpoint(self, endpoint):
+        # finds the endpoint relative to the base url or its parent
+        # and returns its content in raw format
+        if not endpoint:
+            raise AttributeError('endpoint required')
+        ep_urls = [
+            '{baseurl}/{endpoint}'.format(baseurl=self.baseurl,
+                                          endpoint=endpoint),
+            url_sibling(self.baseurl, endpoint)
+        ]
+
+        for ep_url in ep_urls:
+            try:
+                response = self._session.get(ep_url, stream=True)
+                response.raise_for_status()
+                break
+            except requests.RequestException:
+                continue
+        else:
+            raise DALServiceError(
+                "No working {endpoint} endpoint provided".format(
+                    endpoint=endpoint))
+
+        return response.raw
+
+
+class AvailabilityMixin(EndpointMixin):
     """
     Mixing for VOSI availability
     """
@@ -27,16 +54,7 @@ class AvailabilityMixin:
         Service Availability as a
         :py:class:`~pyvo.io.vosi.availability.Availability` object
         """
-        avail_url = '{}/availability'.format(self.baseurl)
-
-        response = self._session.get(avail_url, stream=True)
-
-        try:
-            response.raise_for_status()
-        except requests.RequestException as ex:
-            raise DALServiceError.from_except(ex, avail_url)
-
-        return response.raw
+        return self._get_endpoint('availability')
 
     @lazyproperty
     def availability(self):
@@ -57,7 +75,7 @@ class AvailabilityMixin:
         return self.availability.upsince
 
 
-class CapabilityMixin:
+class CapabilityMixin(EndpointMixin):
     """
     Mixing for VOSI capability
     """
@@ -67,22 +85,7 @@ class CapabilityMixin:
         Returns capabilities as a
         py:class:`~pyvo.io.vosi.availability.Availability` object
         """
-        capa_urls = [
-            '{}/capabilities'.format(self.baseurl),
-            url_sibling(self.baseurl, 'capabilities')
-        ]
-
-        for capa_url in capa_urls:
-            try:
-                response = self._session.get(capa_url, stream=True)
-                response.raise_for_status()
-                break
-            except requests.RequestException:
-                continue
-        else:
-            raise DALServiceError("No working capabilities endpoint provided")
-
-        return response.raw
+        return self._get_endpoint('capabilities')
 
     @lazyproperty
     def capabilities(self):
diff --git a/pyvo/dam/__init__.py b/pyvo/dam/__init__.py
new file mode 100644
index 0000000..ba8c261
--- /dev/null
+++ b/pyvo/dam/__init__.py
@@ -0,0 +1,4 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+from .obscore import *
+
+__all__ = ["ObsCoreMetadata"]
diff --git a/pyvo/dam/obscore.py b/pyvo/dam/obscore.py
new file mode 100644
index 0000000..9d6e3fd
--- /dev/null
+++ b/pyvo/dam/obscore.py
@@ -0,0 +1,105 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+A module for representing data in ObsCore format
+
+"""
+
+__all__ = ['ObsCoreMetadata', 'POLARIZATION_STATES', 'CALIBRATION_LEVELS']
+
+
+# to be moved to ObsCore
+POLARIZATION_STATES = ['I', 'Q', 'U', 'V', 'RR', 'LL', 'RL', 'LR',
+                       'XX', 'YY', 'XY', 'YX', 'POLI', 'POLA']
+CALIBRATION_LEVELS = [0, 1, 2, 3, 4]
+
+
+class ObsCoreMetadata():
+    """
+    Representation of an ObsCore observation
+
+    TBD setters to do validation and unit check.
+    """
+    def __init__(self):
+
+        #          OBSERVATION INFO
+        self.dataproduct_type = None
+        self.dataproduct_subtype = None
+        self.calib_level = None
+
+        #          TARGET INFO
+        self.target_name = None
+        self.target_class = None
+
+        #           DATA DESCRIPTION
+        self.obs_id = None
+        self.obs_title = None
+        self.obs_collection = None
+        self.obs_create_date = None
+        self.obs_creator_name = None
+        self.obs_creator_did = None
+
+        #          CURATION INFORMATION
+        self.obs_release_date = None
+        self.obs_publisher_did = None
+        self.publisher_id = None
+        self.bib_reference = None
+        self.data_rights = None
+
+        #            ACCESS INFORMATION
+        self.access_url = None
+        self.access_format = None
+        self.access_estsize = None
+
+        #            SPATIAL CHARACTERISATION
+        self.s_ra = None
+        self.s_dec = None
+        self.s_fov = None
+        self.s_region = None
+        self.s_resolution = None
+        self.s_xel1 = None
+        self.s_xel2 = None
+        self.s_ucd = None
+        self.s_unit = None
+        self.s_resolution_min = None
+        self.s_resolution_max = None
+        self.s_calib_status = None
+        self.s_stat_error = None
+        self.s_pixel_scale = None
+
+        #            TIME CHARACTERISATION
+        self.t_xel = None
+        self.t_ref_pos = None
+        self.t_min = None
+        self.t_max = None
+        self.t_exptime = None
+        self.t_resolution = None
+        self.t_calib_status = None
+        self.t_stat_error = None
+
+        #            SPECTRAL CHARACTERISATION
+        self.em_xel = None
+        self.em_ucd = None
+        self.em_unit = None
+        self.em_calib_status = None
+        self.em_min = None
+        self.em_max = None
+        self.em_res_power = None
+        self.em_res_power_min = None
+        self.em_res_power_max = None
+        self.em_resolution = None
+        self.em_stat_error = None
+
+        #            OBSERVABLE AXIS
+        self.o_ucd = None
+        self.o_unit = None
+        self.o_calib_status = None
+        self.o_stat_error = None
+
+        #            POLARIZATION CHARACTERISATION
+        self.pol_xel = None
+        self.pol_states = None
+
+        #            PROVENANCE
+        self.instrument_name = None
+        self.facility_name = None
+        self.proposal_id = None
diff --git a/setup.cfg b/setup.cfg
index 1b89f07..63a32ae 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,6 +15,7 @@ show-response = 1
 [tools:pytest]
 minversion = 2.2
 norecursedirs = build docs/_build
+remote_data_strict = true
 
 [ah_bootstrap]
 auto_use = True


Test patch:
diff --git a/pyvo/auth/tests/conftest.py b/pyvo/auth/tests/conftest.py
index f6612eb..f8c9b1a 100644
--- a/pyvo/auth/tests/conftest.py
+++ b/pyvo/auth/tests/conftest.py
@@ -21,7 +21,7 @@ class ContextAdapter(requests_mock.Adapter):
             self._matchers.remove(matcher)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope='function')
 def mocker():
     with requests_mock.Mocker(
         adapter=ContextAdapter(case_sensitive=True)
diff --git a/pyvo/dal/tests/conftest.py b/pyvo/dal/tests/conftest.py
index f6612eb..f8c9b1a 100644
--- a/pyvo/dal/tests/conftest.py
+++ b/pyvo/dal/tests/conftest.py
@@ -21,7 +21,7 @@ class ContextAdapter(requests_mock.Adapter):
             self._matchers.remove(matcher)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope='function')
 def mocker():
     with requests_mock.Mocker(
         adapter=ContextAdapter(case_sensitive=True)
diff --git a/pyvo/dal/tests/data/sia2/capabilities.xml b/pyvo/dal/tests/data/sia2/capabilities.xml
new file mode 100644
index 0000000..2383b83
--- /dev/null
+++ b/pyvo/dal/tests/data/sia2/capabilities.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<vosi:capabilities xmlns:vosi="http://www.ivoa.net/xml/VOSICapabilities/v1.0" xmlns:vs="http://www.ivoa.net/xml/VODataService/v1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+  <capability standardID="ivo://ivoa.net/std/VOSI#capabilities">
+    <interface xsi:type="vs:ParamHTTP">
+      <accessURL use="full">https://example.com/sia/capabilities</accessURL>
+    </interface>
+  </capability>
+  <capability standardID="ivo://ivoa.net/std/VOSI#availability">
+    <interface xsi:type="vs:ParamHTTP">
+      <accessURL use="full">https://example.com/sia/availability</accessURL>
+    </interface>
+  </capability>
+  <!-- SIA-2.0 query capability -->
+  <capability standardID="ivo://ivoa.net/std/SIA#query-2.0">
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/v2query</accessURL>
+    </interface>
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/auth-v2query</accessURL>
+      <securityMethod standardID="ivo://ivoa.net/sso#BasicAA" />
+    </interface>
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/v2query</accessURL>
+      <securityMethod standardID="ivo://ivoa.net/sso#cookie" />
+    </interface>
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/v2query</accessURL>
+      <securityMethod standardID="ivo://ivoa.net/sso#tls-with-certificate" />
+    </interface>
+  </capability>
+</vosi:capabilities>
diff --git a/pyvo/dal/tests/data/sia2/dataset.xml b/pyvo/dal/tests/data/sia2/dataset.xml
new file mode 100644
index 0000000..6bd95ce
--- /dev/null
+++ b/pyvo/dal/tests/data/sia2/dataset.xml
@@ -0,0 +1,167 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<VOTABLE xmlns="http://www.ivoa.net/xml/VOTable/v1.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.3">
+  <RESOURCE type="results">
+    <INFO name="QUERY_STATUS" value="OK" />
+    <INFO name="QUERY_TIMESTAMP" value="2020-02-07T03:37:36.754" />
+    <INFO name="QUERY" value="SELECT * FROM ivoa.ObsCore WHERE dataproduct_type IN ( 'image', 'cube' )" />
+    <TABLE>
+      <FIELD name="calib_level" datatype="int" ucd="meta.code;obs.calib" utype="obscore:ObsDataset.calibLevel">
+        <DESCRIPTION>calibration level (0,1,2,3)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_publisher_did" datatype="char" arraysize="256*" ID="caomPlaneURI" ucd="meta.ref.url;meta.curation" utype="obscore:Curation.PublisherDID">
+        <DESCRIPTION>publisher dataset identifier</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_collection" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:DataID.Collection">
+        <DESCRIPTION>short name for the data colection</DESCRIPTION>
+      </FIELD>
+      <FIELD name="facility_name" datatype="char" arraysize="128*" ucd="meta.id;instr.tel" utype="obscore:Provenance.ObsConfig.Facility.name">
+        <DESCRIPTION>telescope name</DESCRIPTION>
+      </FIELD>
+      <FIELD name="instrument_name" datatype="char" arraysize="128*" ucd="meta.id;instr" utype="obscore:Provenance.ObsConfig.Instrument.name">
+        <DESCRIPTION>instrument name</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_id" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:DataID.observationID">
+        <DESCRIPTION>internal dataset identifier</DESCRIPTION>
+      </FIELD>
+      <FIELD name="dataproduct_type" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:ObsDataset.dataProductType">
+        <DESCRIPTION>type of product</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_release_date" datatype="char" arraysize="23*" ucd="time.release" utype="obscore:Curation.releaseDate" xtype="timestamp">
+        <DESCRIPTION>timestamp of date the data becomes publicly available</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_ra" datatype="double" ucd="pos.eq.ra" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Location.Coord.Position2D.Value2.C1">
+        <DESCRIPTION>RA of central coordinates</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_dec" datatype="double" ucd="pos.eq.dec" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Location.Coord.Position2D.Value2.C2">
+        <DESCRIPTION>DEC of central coordinates</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_fov" datatype="double" ucd="phys.angSize;instr.fov" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Bounds.Extent.diameter">
+        <DESCRIPTION>size of the region covered (~diameter of minimum bounding circle)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_region" datatype="char" arraysize="*" ucd="pos.outline;obs.field" utype="obscore:Char.SpatialAxis.Coverage.Support.Area" xtype="adql:REGION">
+        <DESCRIPTION>region bounded by observation</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_resolution" datatype="double" ucd="pos.angResolution" unit="arcsec" utype="obscore:Char.SpatialAxis.Resolution.refval.value">
+        <DESCRIPTION>typical spatial resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_xel1" datatype="long" ucd="meta.number" utype="obscore:Char.SpatialAxis.numBins1">
+        <DESCRIPTION>dimensions (number of pixels) along one spatial axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="target_name" datatype="char" arraysize="32*" ucd="meta.id;src" utype="obscore:Target.Name">
+        <DESCRIPTION>name of intended target</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_xel2" datatype="long" ucd="meta.number" utype="obscore:Char.SpatialAxis.numBins2">
+        <DESCRIPTION>dimensions (number of pixels) along the other spatial axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_min" datatype="double" ucd="time.start;obs.exposure" unit="d" utype="obscore:Char.TimeAxis.Coverage.Bounds.Limits.StartTime">
+        <DESCRIPTION>start time of observation (MJD)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_max" datatype="double" ucd="time.end;obs.exposure" unit="d" utype="obscore:Char.TimeAxis.Coverage.Bounds.Limits.StopTime">
+        <DESCRIPTION>end time of observation (MJD)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_exptime" datatype="double" ucd="time.duration;obs.exposure" unit="s" utype="obscore:Char.TimeAxis.Coverage.Support.Extent">
+        <DESCRIPTION>exposure time of observation</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_resolution" datatype="double" ucd="time.resolution" unit="s" utype="obscore:Char.TimeAxis.Resolution.refval.value">
+        <DESCRIPTION>typical temporal resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_xel" datatype="long" ucd="meta.number" utype="obscore:Char.TimeAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the time axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_min" datatype="double" ucd="em.wl;stat.min" unit="m" utype="obscore:Char.SpectralAxis.Coverage.Bounds.Limits.LoLimit">
+        <DESCRIPTION>start spectral coordinate value</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_max" datatype="double" ucd="em.wl;stat.max" unit="m" utype="obscore:Char.SpectralAxis.Coverage.Bounds.Limits.HiLimit">
+        <DESCRIPTION>stop spectral coordinate value</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_res_power" datatype="double" ucd="spect.resolution" utype="obscore:Char.SpectralAxis.Resolution.ResolPower.refval">
+        <DESCRIPTION>typical spectral resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_xel" datatype="long" ucd="meta.number" utype="obscore:Char.SpectralAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the energy axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="pol_xel" datatype="long" ucd="meta.number" utype="obscore:Char.PolarizationAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the polarization axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_ucd" datatype="char" arraysize="32*" ucd="meta.ucd" utype="obscore:Char.SpectralAxis.ucd">
+        <DESCRIPTION>UCD describing the spectral axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="pol_states" datatype="char" arraysize="32*" ucd="meta.code;phys.polarization" utype="obscore:Char.PolarizationAxis.stateList">
+        <DESCRIPTION>polarization states present in the data</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_url" datatype="char" arraysize="*" ucd="meta.ref.url" utype="obscore:Access.Reference" xtype="clob">
+        <DESCRIPTION>URL to download the data</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_estsize" datatype="long" ucd="phys.size;meta.file" unit="kbyte" utype="obscore:Access.Size">
+        <DESCRIPTION>estimated size of the download</DESCRIPTION>
+      </FIELD>
+      <FIELD name="o_ucd" datatype="char" arraysize="32*" ucd="meta.ucd" utype="obscore:Char.ObservableAxis.ucd">
+        <DESCRIPTION>UCD describing the observable axis (pixel values)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_format" datatype="char" arraysize="128*" ucd="meta.code.mime" utype="obscore:Access.Format">
+        <DESCRIPTION>format of the data file(s)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="core_id" datatype="char" arraysize="36" xtype="uuid">
+        <DESCRIPTION>primary key</DESCRIPTION>
+      </FIELD>
+      <FIELD name="lastModified" datatype="char" arraysize="23*" xtype="timestamp">
+        <DESCRIPTION>timestamp of last modification of the metadata</DESCRIPTION>
+      </FIELD>
+      <DATA>
+        <TABLEDATA>
+          <TR>
+            <TD>1</TD>
+            <TD>ivo://cadc.nrc.ca/TEST?C190508_1442_SCI/C190508_1442_SCI</TD>
+            <TD>TEST</TD>
+            <TD>TEST-1.6m</TD>
+            <TD>TEST-INSTR</TD>
+            <TD>TEST-DATASET</TD>
+            <TD>image</TD>
+            <TD>2000-01-01T00:00:00.000</TD>
+            <TD>253.0637009871805</TD>
+            <TD>69.94853437563255</TD>
+            <TD>0.7176383627131244</TD>
+            <TD>polygon 252.33774699999995 70.20847500000002 253.83481099999992 70.19265699999998 253.77173199999996 69.68570399999999 252.31050299999995 69.70114700000002</TD>
+            <TD />
+            <TD>2048</TD>
+            <TD>BYW1651+6953</TD>
+            <TD>2048</TD>
+            <TD>58612.30703703704</TD>
+            <TD>58612.30726851852</TD>
+            <TD>20.295</TD>
+            <TD>0.1</TD>
+            <TD>1</TD>
+            <TD>1.1699999999999998E-6</TD>
+            <TD>1.33E-6</TD>
+            <TD />
+            <TD>1</TD>
+            <TD />
+            <TD />
+            <TD />
+            <TD>https://example.com/datalink?runid=i4oh43a0oky3dyvp&amp;ID=ivo%3A%2F%2Fexample.com%2FTEST%3FC190508_1442_SCI%2FC190508_1442_SCI</TD>
+            <TD />
+            <TD>phot.count</TD>
+            <TD>application/x-votable+xml;content=datalink</TD>
+            <TD>f0a8f0ad-be05-4adf-ab3d-8b13c45b79b1</TD>
+            <TD>2019-09-19T22:17:07.594</TD>
+          </TR>
+        </TABLEDATA>
+      </DATA>
+    </TABLE>
+    <INFO name="QUERY_STATUS" value="OK" />
+  </RESOURCE>
+  <RESOURCE type="meta" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="*" value="ivo://test.com/datalink" />
+    <PARAM name="standardID" datatype="char" arraysize="*" value="ivo://ivoa.net/std/DataLink#links-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="62" value="https://example.com/caom2ops/datalink" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="*" ref="caomPlaneURI" value="" />
+      <PARAM name="RESPONSEFORMAT" datatype="char" arraysize="*" value="application/x-votable+xml;content=datalink">
+        <VALUES>
+          <OPTION value="application/x-votable+xml;content=datalink" />
+          <OPTION value="application/x-download-manifest+txt" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+</VOTABLE>
diff --git a/pyvo/dal/tests/setup_package.py b/pyvo/dal/tests/setup_package.py
index d6114de..903ea19 100644
--- a/pyvo/dal/tests/setup_package.py
+++ b/pyvo/dal/tests/setup_package.py
@@ -11,6 +11,7 @@ def get_package_data():
         os.path.join('data/tap', '*.xml'),
         os.path.join('data/scs', '*.xml'),
         os.path.join('data/sia', '*.xml'),
+        os.path.join('data/sia2', '*.xml'),
         os.path.join('data/sla', '*.xml'),
         os.path.join('data/ssa', '*.xml'),
         os.path.join('data/datalink', '*.xml'),
diff --git a/pyvo/dal/tests/test_adhoc.py b/pyvo/dal/tests/test_adhoc.py
new file mode 100644
index 0000000..7696d02
--- /dev/null
+++ b/pyvo/dal/tests/test_adhoc.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Tests for pyvo.dal.adhoc
+"""
+import datetime
+from astropy import units as u
+from astropy.time import Time
+import pytest
+
+from pyvo.dal.adhoc import AxisParamMixin, SodaQuery
+
+
+def test_pos():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    test_obj.pos.add((1, 2, 3)*u.deg)
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0']
+
+    test_obj.pos.add((1, 2, 3, 4))
+    assert len(test_obj._pos) == 2
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0', 'RANGE 1.0 2.0 3.0 4.0']
+
+    # duplicates are ignored
+    test_obj.pos.add((1, 2, 3))
+    assert len(test_obj._pos) == 2
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0', 'RANGE 1.0 2.0 3.0 4.0']
+
+    # polygon
+    test_obj.pos.add((1, 2, 3, 4, 5, 6))
+    assert len(test_obj._pos) == 3
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0', 'RANGE 1.0 2.0 3.0 4.0',
+                               'POLYGON 1.0 2.0 3.0 4.0 5.0 6.0']
+
+    # deletes
+    test_obj.pos.remove((1, 2, 3, 4))
+    assert len(test_obj._pos) == 2
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0',
+                               'POLYGON 1.0 2.0 3.0 4.0 5.0 6.0']
+
+    # test borders
+    test_obj.pos.discard((1, 2, 3)*u.deg)
+    test_obj.pos.discard((1, 2, 3, 4, 5, 6))
+    assert(len(test_obj._pos) == 0)
+    test_obj.pos.add((0, 90, 90))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['CIRCLE 0.0 90.0 90.0']
+    test_obj.pos.pop()
+    test_obj.pos.add((360, -90, 1))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['CIRCLE 360.0 -90.0 1.0']
+    test_obj.pos.pop()
+    test_obj.pos.add((0, 360, -90, 90))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['RANGE 0.0 360.0 -90.0 90.0']
+    test_obj.pos.pop()
+    test_obj.pos.add((0, 0, 180, 90, 270, -90))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['POLYGON 0.0 0.0 180.0 90.0 270.0 -90.0']
+
+    # errors
+    test_obj.pos.pop()
+    with pytest.raises(ValueError):
+        test_obj.pos.add(('A', 2, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((-2, 7, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((3, 99, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((2, 7, 91))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((-1, 7, 3, 4))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((2, 1, 3, 4))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((1, 2, 4, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((-2, 7, 5, 9, 10, 10))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((2, 99, 5, 9, 10, 10))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((1, 2, 3, 4, 5, 6, 7))
+
+
+def test_band():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    assert not hasattr(test_obj, '_band')
+    test_obj.band.add(33)
+    assert 33 in test_obj.band
+    assert test_obj['BAND'] == ['33.0 33.0']
+    test_obj.band.add((50*u.meter, 500))
+    assert 33 in test_obj.band
+    assert (50*u.meter, 500) in test_obj.band
+    assert test_obj['BAND'] == ['33.0 33.0', '50.0 500.0']
+    test_obj.band.discard(33)
+    assert (50*u.meter, 500) in test_obj.band
+    assert test_obj['BAND'] == ['50.0 500.0']
+    test_obj.band.pop()
+    assert not test_obj.band
+    assert not test_obj['BAND']
+    test_obj.band.add((float('-inf'), 33))
+    assert (float('-inf'), 33) in test_obj.band
+    assert test_obj.band.dal == ['-inf 33.0']
+    test_obj.band.clear()
+    test_obj.band.add((33, float('inf')))
+    assert (33, float('inf')) in test_obj.band
+    assert test_obj.band.dal == ['33.0 inf']
+    test_obj.clear()
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.band.add(())
+    with pytest.raises(ValueError):
+        test_obj.band.add((1, 2, 3))
+    with pytest.raises(ValueError):
+        test_obj.band.add(('INVALID', 6))
+    with pytest.raises(ValueError):
+        test_obj.band.add((3, 1))
+
+
+def test_time():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    assert not hasattr(test_obj, '_time')
+    now = Time(datetime.datetime.now())
+    test_obj.time.add(now)
+    assert now in test_obj.time
+    assert test_obj['TIME'] == ['{now} {now}'.format(now=now.mjd)]
+    min_time = '2010-01-01T00:00:00.000Z'
+    max_time = '2010-01-01T01:00:00.000Z'
+    test_obj.time.add((min_time, max_time))
+    assert now in test_obj.time
+    assert (min_time, max_time) in test_obj.time
+    assert test_obj['TIME'] == ['{now} {now}'.format(now=now.mjd),
+                                '{min} {max}'.format(min=Time(min_time).mjd,
+                                                     max=Time(max_time).mjd)]
+    test_obj.time.discard(now)
+    assert (min_time, max_time) in test_obj.time
+    assert test_obj['TIME'] == ['{min} {max}'.format(min=Time(min_time).mjd,
+                                                     max=Time(max_time).mjd)]
+    test_obj.time.pop()
+    assert not test_obj.time
+    assert not test_obj['TIME']
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.time.add([])
+    with pytest.raises(ValueError):
+        test_obj.time.add([now, min_time, max_time])
+    with pytest.raises(ValueError):
+        test_obj.time.add(['INVALID'])
+    with pytest.raises(ValueError):
+        test_obj.time.add([max_time, min_time])
+
+
+def test_pol():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    assert not hasattr(test_obj, '_pol')
+    test_obj.pol.add('YY')
+    assert 'YY' in test_obj.pol
+    assert test_obj['POL'] == ['YY']
+    test_obj.pol.add('POLI')
+    assert 'YY' in test_obj.pol
+    assert 'POLI' in test_obj.pol
+    assert test_obj['POL'] == ['YY', 'POLI']
+    # test duplicate
+    test_obj.pol.add('POLI')
+    assert 'YY' in test_obj.pol
+    assert 'POLI' in test_obj.pol
+    assert test_obj['POL'] == ['YY', 'POLI']
+    test_obj.pol.remove('YY')
+    assert 'POLI' in test_obj.pol
+    assert test_obj['POL'] == ['POLI']
+    test_obj.pol.pop()
+    assert not test_obj._pol
+    assert not test_obj['POL']
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.pol.add(None)
+    with pytest.raises(ValueError):
+        test_obj.pol.add(['INVALID'])
+
+
+def test_soda_query():
+    test_obj = SodaQuery(baseurl='some/url')
+    test_obj.circle = (2, 3, 5)
+    assert test_obj._circle == (2, 3, 5)
+    assert test_obj['CIRCLE'] == '2.0 3.0 5.0'
+    assert test_obj._circle
+    assert not hasattr(test_obj, '_polygon')
+    assert not hasattr(test_obj, '_range')
+
+    test_obj.range = (8, 9, 3, 4)*u.deg
+    assert test_obj['POS'] == 'RANGE 8.0 9.0 3.0 4.0'
+    assert test_obj._range is not None
+    assert not hasattr(test_obj, '_polygon')
+    assert not hasattr(test_obj, '_circle')
+
+    test_obj.polygon = (1, 2, 3, 4, 5, 6)
+    assert test_obj['POLYGON'] == '1.0 2.0 3.0 4.0 5.0 6.0'
+    assert test_obj._polygon
+    assert not hasattr(test_obj, '_range')
+    assert not hasattr(test_obj, '_circle')
+
+    del test_obj.polygon
+    assert not hasattr(test_obj, '_polygon')
+    assert not hasattr(test_obj, '_circle')
+    assert not hasattr(test_obj, '_range')
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.circle = ('A', 1, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (1, 1, 2, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (-1, 1, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (1, 99, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (1, 1, 91)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 2, 3)
+    with pytest.raises(ValueError):
+        test_obj.range = (2, 1, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 2, 4, 3)
+    with pytest.raises(ValueError):
+        test_obj.range = (-1, 2, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (2, 1000, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 1, -91, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 1, 3, 92)
+    with pytest.raises(ValueError):
+        test_obj.polygon = (1, 2, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.polygon = (2, 1, 3, 4, 5, 6, 7)
diff --git a/pyvo/dal/tests/test_params.py b/pyvo/dal/tests/test_params.py
index 06dd918..63f50f6 100644
--- a/pyvo/dal/tests/test_params.py
+++ b/pyvo/dal/tests/test_params.py
@@ -7,7 +7,7 @@ from functools import partial
 from urllib.parse import parse_qsl
 
 from pyvo.dal.adhoc import DatalinkResults
-from pyvo.dal.params import find_param_by_keyword, get_converter
+from pyvo.dal.params import find_param_by_keyword, get_converter, AbstractDalQueryParam
 from pyvo.dal.exceptions import DALServiceError
 
 import pytest
@@ -186,3 +186,23 @@ def test_inf():
     proc = datalink[0]
 
     proc.process(band=(6000, +np.inf) * u.Angstrom)
+
+
+def test_dal_query_param():
+    class Test(AbstractDalQueryParam):
+        def get_dal_format(self, item):
+            return str(item)
+    # check test_obs behaves like a set but also holds the dal representation
+    test_obs = Test()
+    test_obs.add(1)
+    assert 1 in test_obs
+    assert test_obs.dal == ['1']
+    test_obs.add(2)
+    test_obs.add(3)
+    assert len(test_obs) == 3
+    assert test_obs.dal == ['1', '2', '3']
+    assert {2, 3} < test_obs
+    assert {1, 2, 3, 4} > test_obs
+    test_obs.clear()
+    assert len(test_obs) == 0
+    assert len(test_obs.dal) == 0
diff --git a/pyvo/dal/tests/test_query.py b/pyvo/dal/tests/test_query.py
index 44ba7da..d75b1c9 100644
--- a/pyvo/dal/tests/test_query.py
+++ b/pyvo/dal/tests/test_query.py
@@ -30,7 +30,7 @@ get_pkg_data_contents = partial(
     get_pkg_data_contents, package=__package__, encoding='binary')
 
 
-@pytest.fixture(autouse=True, scope='module')
+@pytest.fixture()
 def register_mocks(mocker):
     with ExitStack() as stack:
         matchers = [
@@ -145,6 +145,7 @@ def _test_records(records):
 
 
 @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+@pytest.mark.usefixtures('register_mocks')
 class TestDALService:
     def test_init(self):
         """Test if baseurl if passed correctly"""
@@ -208,6 +209,7 @@ class TestDALService:
 
 
 @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+@pytest.mark.usefixtures('register_mocks')
 class TestDALQuery:
     def test_url(self):
         queries = (
@@ -245,6 +247,7 @@ class TestDALQuery:
 
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W03')
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W06')
+@pytest.mark.usefixtures('register_mocks')
 class TestDALResults:
     def test_init(self):
         dalresults = DALResults.from_result_url(
@@ -373,6 +376,7 @@ class TestDALResults:
 
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W03')
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W06')
+@pytest.mark.usefixtures('register_mocks')
 class TestRecord:
     def test_itemaccess(self):
         record = DALResults.from_result_url(
diff --git a/pyvo/dal/tests/test_sia.py b/pyvo/dal/tests/test_sia.py
index 734a087..87ccda2 100644
--- a/pyvo/dal/tests/test_sia.py
+++ b/pyvo/dal/tests/test_sia.py
@@ -19,7 +19,7 @@ get_pkg_data_contents = partial(
 sia_re = re.compile('http://example.com/sia.*')
 
 
-@pytest.fixture(autouse=True, scope='module')
+@pytest.fixture()
 def register_mocks(mocker):
     with mocker.register_uri(
         'GET', 'http://example.com/querydata/image.fits',
@@ -43,6 +43,7 @@ def _test_result(result):
 
 
 @pytest.mark.usefixtures('sia')
+@pytest.mark.usefixtures('register_mocks')
 @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
 def test_search():
     results = search('http://example.com/sia', pos=(288, 15))
@@ -53,6 +54,7 @@ def test_search():
 
 class TestSIAService:
     @pytest.mark.usefixtures('sia')
+    @pytest.mark.usefixtures('register_mocks')
     @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
     @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W42")
     @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W49")
diff --git a/pyvo/dal/tests/test_sia2.py b/pyvo/dal/tests/test_sia2.py
new file mode 100644
index 0000000..315539e
--- /dev/null
+++ b/pyvo/dal/tests/test_sia2.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Tests for pyvo.dal.sia
+"""
+from functools import partial
+import re
+
+import pytest
+
+from pyvo.dal.sia2 import search, SIAService, SIAQuery
+
+import astropy.units as u
+from astropy.utils.data import get_pkg_data_contents
+
+get_pkg_data_contents = partial(
+    get_pkg_data_contents, package=__package__, encoding='binary')
+
+sia_re = re.compile('https://example.com/sia/v2query*')
+capabilities_url = 'https://example.com/sia/capabilities'
+
+
+@pytest.fixture()
+def sia(mocker):
+    with mocker.register_uri(
+        'GET', sia_re, content=get_pkg_data_contents('data/sia2/dataset.xml')
+    ) as matcher:
+        yield matcher
+
+
+@pytest.fixture()
+def capabilities(mocker):
+    with mocker.register_uri(
+        'GET', capabilities_url,
+            content=get_pkg_data_contents('data/sia2/capabilities.xml')
+    ) as matcher:
+        yield matcher
+
+
+def _test_result(record):
+    assert record.obs_collection == 'TEST'
+    assert record.obs_id == 'TEST-DATASET'
+    assert record.instrument_name == 'TEST-INSTR'
+    assert record.facility_name == 'TEST-1.6m'
+
+
+@pytest.mark.usefixtures('sia')
+@pytest.mark.usefixtures('capabilities')
+@pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+def test_search():
+    results = search('https://example.com/sia',
+                     pos=(33.3*u.deg, 4.2*u.deg, 0.0166*u.deg))
+    result = results[0]
+
+    _test_result(result)
+
+
+class TestSIAService:
+    @pytest.mark.usefixtures('sia')
+    @pytest.mark.usefixtures('capabilities')
+    @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+    @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W42")
+    @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W49")
+    def test_search(self):
+        service = SIAService('https://example.com/sia')
+
+        positions = [
+            (2, 4, 0.0166 * u.deg),
+            (12, 12.5, 34, 36),
+            (12.0*u.deg, 34.0*u.deg,
+             14.0*u.deg, 35.0*u.deg,
+             14.0*u.deg, 36.0*u.deg,
+             12.0*u.deg, 35.0*u.deg)]
+
+        # each position
+        for pos in positions:
+            results = service.search(pos=pos)
+            result = results[0]
+            _test_result(result)
+
+        # all positions
+        results = service.search(pos=positions)
+        result = results[0]
+        _test_result(result)
+
+
+class TestSIAQuery():
+
+    def test_query(self):
+        query = SIAQuery('someurl')
+        query.field_of_view.add((10, 20))
+        assert query['FOV'] == ['10.0 20.0']
+        query.field_of_view.add((1*u.rad, 60))
+        assert query['FOV'] == ['10.0 20.0', '57.29577951308232 60.0']
+
+        query.spatial_resolution.add((1*u.arcsec, 2))
+        assert query['SPATRES'] == ['1.0 2.0']
+
+        query.spectral_resolving_power.add((3, 5))
+        assert query['SPECRP'] == ['3 5']
+
+        query.exptime.add((25, 50))
+        assert query['EXPTIME'] == ['25.0 50.0']
+
+        query.timeres.add((1, 3))
+        assert query['TIMERES'] == ['1.0 3.0']
+
+        query.publisher_did.add('ID1')
+        query.publisher_did.add('ID2')
+        assert query['ID'] == ['ID1', 'ID2']
+
+        query.facility.add('TEL1')
+        assert query['FACILITY'] == ['TEL1']
+
+        query.collection.add('ABC')
+        query.collection.add('EFG')
+        assert query['COLLECTION'] == ['ABC', 'EFG']
+
+        query.instrument.add('INST1')
+        assert query['INSTRUMENT'] == ['INST1']
+
+        query.data_type.add('TYPEA')
+        assert query['DPTYPE'] == ['TYPEA']
+
+        query.calib_level.add(0)
+        query.calib_level.add(1)
+        assert query['CALIB'] == ['0', '1']
+
+        query.target_name.add('TARGET1')
+        assert query['TARGET'] == ['TARGET1']
+
+        query.res_format.add('pdf')
+        assert query['FORMAT'] == ['pdf']
+
+        query.maxrec = 1000
+        assert query['MAXREC'] == '1000'
diff --git a/pyvo/dal/tests/test_sia2_remote.py b/pyvo/dal/tests/test_sia2_remote.py
new file mode 100644
index 0000000..54de302
--- /dev/null
+++ b/pyvo/dal/tests/test_sia2_remote.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Tests for pyvo.dal.sia2 against remote services
+"""
+
+import pytest
+
+from pyvo.dal.sia2 import search, SIAService
+
+import astropy.units as u
+
+CADC_SIA_URL = 'https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/sia'
+
+
+@pytest.mark.remote_data
+class TestSIACadc():
+    # Tests the SIA2 client against the CADC SIA service
+
+    def test_service(self):
+        cadc = SIAService(baseurl=CADC_SIA_URL)
+        assert cadc.availability
+        assert cadc.availability.available
+        assert cadc.availability.notes
+        assert cadc.availability.notes[0] == 'service is accepting queries'
+        assert cadc.capabilities
+
+    def test_pos(self):
+        results = search(CADC_SIA_URL, pos=(2.8425, 74.4846, 0.001))
+        assert len(results) > 10
+
+        # limit results to 5 to expedite tests
+        results = search(CADC_SIA_URL, pos=(2.8425, 74.4846, 0.001), maxrec=5)
+        assert len(results) == 5
+        # check attributes of a record
+        record = results[0]
+        record.dataproduct_type
+        record.dataproduct_subtype
+        record.calib_level
+
+        #          TARGET INFO
+        record.target_name
+        record.target_class
+
+        #           DATA DESCRIPTION
+        record.obs_id
+        record.obs_title
+        record.obs_collection
+        record.obs_create_date
+        record.obs_creator_name
+        record.obs_creator_did
+
+        #          CURATION INFORMATION
+        record.obs_release_date
+        record.obs_publisher_did
+        record.publisher_id
+        record.bib_reference
+        record.data_rights
+
+        #            ACCESS INFORMATION
+        record.access_url
+        record.access_format
+        record.access_estsize
+
+        #            SPATIAL CHARACTERISATION
+        record.s_ra
+        record.s_dec
+        record.s_fov
+        record.s_region
+        record.s_resolution
+        record.s_xel1
+        record.s_xel2
+        record.s_ucd
+        record.s_unit
+        record.s_resolution_min
+        record.s_resolution_max
+        record.s_calib_status
+        record.s_stat_error
+        record.s_pixel_scale
+
+        #            TIME CHARACTERISATION
+        record.t_xel
+        record.t_ref_pos
+        record.t_min
+        record.t_max
+        record.t_exptime
+        record.t_resolution
+        record.t_calib_status
+        record.t_stat_error
+
+        #            SPECTRAL CHARACTERISATION
+        record.em_xel
+        record.em_ucd
+        record.em_unit
+        record.em_calib_status
+        record.em_min
+        record.em_max
+        record.em_res_power
+        record.em_res_power_min
+        record.em_res_power_max
+        record.em_resolution
+        record.em_stat_error
+
+        #            OBSERVABLE AXIS
+        record.o_ucd
+        record.o_unit
+        record.o_calib_status
+        record.o_stat_error
+
+        #            POLARIZATION CHARACTERISATION
+        record.pol_xel
+        record.pol_states
+
+        #            PROVENANCE
+        record.instrument_name
+        record.facility_name
+        record.proposal_id
+
+    def test_band(self):
+        results = search(CADC_SIA_URL, band=(0.0002, 0.0003), maxrec=5)
+        # TODO - correctness
+        assert len(results) == 5
+
+    def test_time(self):
+        results = search(CADC_SIA_URL,
+                         time=('2002-01-01T00:00:00.00',
+                               '2002-01-02T00:00:00.00'),
+                         maxrec=5)
+        assert len(results) == 5
+
+    def test_pol(self):
+        results = search(CADC_SIA_URL, pol=['YY', 'U'], maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 'YY' in rr.pol_states or 'U' in rr.pol_states
+
+    def test_fov(self):
+        results = search(CADC_SIA_URL, field_of_view=(10, 20), maxrec=5)
+        assert len(results) == 5
+        # how to test values
+
+    def test_spatial_res(self):
+        results = search(CADC_SIA_URL, spatial_resolution=(1, 2), maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1*u.arcsec <= rr.s_resolution <= 2*u.arcsec
+
+    def test_spec_resp(self):
+        results = search(CADC_SIA_URL, spectral_resolving_power=(1, 2), maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1 <= rr.em_res_power <= 2
+
+    def test_exptime(self):
+        results = search(CADC_SIA_URL, exptime=(1, 2),
+                         maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1*u.second <= rr.t_exptime <= 2*u.second
+
+    def test_timeres(self):
+        results = search(CADC_SIA_URL, timeres=(1, 2),
+                         maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1 * u.second <= rr.t_resolution <= 2 * u.second
+
+    def test_publisher_did(self):
+        ids = ['ivo://cadc.nrc.ca/CFHT?447231/447231o',
+               'ivo://cadc.nrc.ca/CFHT?447232/447232o']
+        results = search(CADC_SIA_URL, publisher_did=ids)
+        assert len(results) == 2
+        assert results[0].obs_publisher_did in ids
+        assert results[1].obs_publisher_did in ids
+
+    def test_facility(self):
+        results = search(CADC_SIA_URL, facility='JCMT', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.facility_name == 'JCMT'
+
+    def test_collection(self):
+        results = search(CADC_SIA_URL, collection='CFHT', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.obs_collection == 'CFHT'
+
+    def test_instrument(self):
+        results = search(CADC_SIA_URL, instrument='SCUBA-2', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.instrument_name == 'SCUBA-2'
+
+    def test_dataproduct_type(self):
+        results = search(CADC_SIA_URL, data_type='image', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.dataproduct_type == 'image'
+
+    def test_target_name(self):
+        results = search(CADC_SIA_URL, target_name='OGF:t028', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.target_name == 'OGF:t028'
+
+    def test_res_format(self):
+        results = search(
+            CADC_SIA_URL,
+            res_format='application/x-votable+xml;content=datalink',
+            maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.access_format == \
+                   'application/x-votable+xml;content=datalink'
diff --git a/pyvo/registry/tests/conftest.py b/pyvo/registry/tests/conftest.py
index f6612eb..f8c9b1a 100644
--- a/pyvo/registry/tests/conftest.py
+++ b/pyvo/registry/tests/conftest.py
@@ -21,7 +21,7 @@ class ContextAdapter(requests_mock.Adapter):
             self._matchers.remove(matcher)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope='function')
 def mocker():
     with requests_mock.Mocker(
         adapter=ContextAdapter(case_sensitive=True)