-
Notifications
You must be signed in to change notification settings - Fork 70
Sensitivity analysis MVP: MNPE/NPE posterior #729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 61 commits
Commits
Show all changes
77 commits
Select commit
Hold shift + click to select a range
bb79bb4
Add sensitivity-analysis pipeline + per-episode recording
cvolkcvolk 48fba5d
Add sensitivity-analysis sweep configs
cvolkcvolk 5a4e50d
Fix episode_writer for MetricsCfg configclass
cvolkcvolk ee5b406
Discard sbi training logs during report fits
cvolkcvolk 95d1d84
Expand shiny/matte sweep to 1000 jobs
cvolkcvolk 6224293
Replace HTML report with a single-PDF sensitivity report
cvolkcvolk 730a1db
Fix PDF suptitle clipping on narrow single-factor figures
cvolkcvolk 47fdcb0
Park multi-factor and multi-object sweep configs off the MVP branch
cvolkcvolk 7ac550a
Merge branch 'main' into cvolk/feature/sensitivity_analysis_mvp1
cvolkcvolk 5f08550
Densify verbose inline comments in the sensitivity module
cvolkcvolk 3b51bae
Move log_uniform sampling out of the MVP
cvolkcvolk 8372846
Slim the sensitivity module: drop module docstrings and the sbi null-…
cvolkcvolk 3e67c1c
Add a shared EmpiricalAnalyzer base for the KDE and frequency-table a…
cvolkcvolk 6699cf7
Split analyzer module into base/posterior/empirical + factory
cvolkcvolk 73e792e
Move sensitivity synthetic-data generators into tests/utils
cvolkcvolk 383aa79
Move sensitivity CLI scripts into the analysis package
cvolkcvolk 5447928
Reduce MVP to KDE + MNPE; park NPE and FrequencyTable analyzers
cvolkcvolk f4a3fad
Make sensitivity factory asserts and docstrings self-contained
cvolkcvolk a9d05b8
Move episode_writer from the analysis package into evaluation
cvolkcvolk d737e15
Drop the single-PNG analyze CLI; PDF report is the sole renderer
cvolkcvolk 9eea5fc
Drop the synthetic task_duration outcome from the continuous generator
cvolkcvolk dcb48a4
Use current-year-only copyright headers in the sensitivity workstream
cvolkcvolk bad0760
Hoist episode_writer import to eval_runner module top
cvolkcvolk c2c5638
Clarify SliceSpec as plain dataset provenance
cvolkcvolk 4d9509c
Delete the unused synthetic continuous data generator
cvolkcvolk 102c1ce
Hoist shared plot styling into named constants
cvolkcvolk 3ec444c
Add MetricsManager.compute_per_episode
cvolkcvolk 36b993c
Reuse MetricsManager in episode_writer; drop task_duration
cvolkcvolk 4c53619
Rewrite compute_per_episode with explicit loops
cvolkcvolk c2142d5
Rename generate_report CLI module to generate_pdf_report
cvolkcvolk 52d615b
Rework sensitivity docstrings to be concise and self-contained
cvolkcvolk ffa1473
Empty the sensitivity package __init__
cvolkcvolk be64ae3
Label binary-outcome rug as = 0 / = 1, not >= 0.5 threshold
cvolkcvolk 43ac62a
Single-source SUCCESS_THRESHOLD and clarify continuous-factor error
cvolkcvolk a405da0
Match episode_idx loop variable to the emitted JSONL key
cvolkcvolk b6c2d4b
Add empirical success-rate plots for binary outcomes
cvolkcvolk 89cdc1b
Split continuous success-rate curves by a categorical factor
cvolkcvolk 0d41041
Snapshot sensitivity MVP before robolab-matching refactor
cvolkcvolk 3af9edf
Refactor sensitivity analysis to mirror robolab (MNPE + NPE)
cvolkcvolk 7d02195
Render continuous marginals as density curves; add rich multi-factor …
cvolkcvolk 432a8ea
Clean up sensitivity docstrings and hoist stdlib imports
cvolkcvolk 5aeba86
Address PR review comments
cvolkcvolk ea2355f
Sensitivity cleanup: top-level imports, merge report CLI, eval/ outputs
cvolkcvolk e695b37
Commit to binary outcomes in the conditioning default
cvolkcvolk a12aec8
Add sensitivity analysis documentation page
cvolkcvolk 4a0a47b
Address docs review comments on the sensitivity page
cvolkcvolk 7871aa1
Relocate synthetic generator into the package and dedupe its builders
cvolkcvolk 096ba3e
Decouple plot_marginals from the analyzer
cvolkcvolk b9a9cf5
Model synthetic factors as frozen dataclasses
cvolkcvolk 0e688ee
Move default_observation from the analyzer to the dataset
cvolkcvolk 1dac0e3
Remove sensitivity sweep eval configs from the MVP PR
cvolkcvolk e41e375
Drop the slice block from factors.yaml; TODO a data filter
cvolkcvolk 57e7f24
Outcomes are an analysis-time query, not part of factors.yaml
cvolkcvolk 2ee03e4
Revert .gitignore change
cvolkcvolk 6075a55
Move per-episode recording to a follow-up PR
cvolkcvolk fe4cad6
Merge branch 'main' into cvolk/feature/sensitivity_analysis_mvp1
cvolkcvolk 33b27e5
Move sensitivity deps to runtime (sbi, scipy, matplotlib)
cvolkcvolk adf430d
Require at least one --observation value (nargs=+)
cvolkcvolk 88ff6b8
Docs: collapse the synthetic demo to one example
cvolkcvolk 2b43c8d
Merge the two MNPE synthetic datasets into make_mixed_dataset
cvolkcvolk f0afbaf
Merge branch 'main' into cvolk/feature/sensitivity_analysis_mvp1
cvolkcvolk 48b58bf
Assert continuous factors carry a range before normalizing
cvolkcvolk ff00343
Seed the sensitivity report RNG for reproducibility
cvolkcvolk 1cec287
Drop the unused SensitivityDataset.prior property
cvolkcvolk 9599d51
Test factors.yaml / episode_summary.jsonl parsing
cvolkcvolk 804759f
Model FactorSpec.type as a FactorType enum
cvolkcvolk 308580d
Type FactorSpec.range as list[tuple[float, float]]
cvolkcvolk 6f4a007
Move synthetic dataset generator into the tests package
cvolkcvolk 68cca97
Clarify FactorSchema docstring
cvolkcvolk b1a25b7
Define theta and x in the SensitivityAnalyzer docstring
cvolkcvolk 8a24daa
Tighten sensitivity docs wording
cvolkcvolk 0778af2
Define the posterior in the sensitivity docs
cvolkcvolk f317570
Mark per-episode recording as a follow-up in the sensitivity docs
cvolkcvolk 934999a
Gloss theta and x in the sensitivity docs
cvolkcvolk 3c39843
Clarify the report-reading interpretation in the sensitivity docs
cvolkcvolk bc917dd
Note the planned vector-factor scalarization in the sensitivity docs
cvolkcvolk 9006912
Signpost the continuous-first theta layout in the analyzer
cvolkcvolk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
155 changes: 155 additions & 0 deletions
155
docs/pages/concepts/policy/concept_sensitivity_analysis.rst
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| Sensitivity Analysis | ||
| ==================== | ||
|
|
||
| The sensitivity-analysis toolbox answers a single question about a policy: | ||
| *which environment conditions drive success?* Given the per-episode results of an | ||
| evaluation sweep — where factors such as lighting, object mass, or table material were | ||
| varied — it fits a posterior over those factors conditioned on the outcome and renders | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| one figure summarising which factor values are associated with success. | ||
|
|
||
| Why a joint posterior, not a success rate per factor? | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
| ----------------------------------------------------- | ||
|
|
||
| The simplest analysis would chart a success rate for each factor independently. That hides | ||
| the two things that matter most in a multi-factor sweep: | ||
|
|
||
| - **Factors interact.** How much light a policy needs can depend on the object — a matte | ||
| object may succeed at low light while a shiny one needs far more. A per-factor | ||
| "success vs light" curve averages over objects and reports one blurry gate that is wrong | ||
| for both. The joint posterior keeps the interaction, so you can condition on a specific | ||
| object and see its gate. | ||
| - **Factors confound each other.** If bright-light episodes also happened to use an easy | ||
| object, a per-factor light chart cannot tell which one drove success. Modelling all | ||
| factors together attributes the effect to the factor that actually carries it. | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
|
|
||
| The per-factor rate is a projection of the joint posterior — derivable from it, but not the | ||
| other way around. The toolbox therefore always fits the joint — via simulation-based | ||
| inference (MNPE or NPE) — and reads the per-factor marginals from it. | ||
|
|
||
| How it works | ||
| ------------ | ||
|
|
||
| The toolbox is a thin analysis layer over `sbi <https://sbi.readthedocs.io>`_'s | ||
| neural posterior estimators. The flow is: | ||
|
|
||
|
cvolkcvolk marked this conversation as resolved.
|
||
| 1. **Per-episode recording.** During evaluation, ``episode_writer`` appends one row per | ||
| episode to an ``episode_summary.jsonl`` file. | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| 2. **Schema.** A ``factors.yaml`` declares the *factors* — which ``arena_env_args`` columns | ||
| were varied and whether each is continuous or categorical, plus the continuous ranges | ||
| that were swept (so the analyzer's prior matches the simulation). It does **not** list | ||
| outcomes — *which* outcome to condition on is chosen at analysis time, not saved here. | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
| 3. **Inference.** ``SensitivityAnalyzer`` loads the pair, trains an estimator on the full | ||
| ``(theta, x)`` jointly, and samples the joint posterior conditioned on a chosen | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| observation (by default, success). | ||
| 4. **Report.** A smooth density curve for each continuous factor and a probability bar chart | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| for each categorical factor. | ||
|
|
||
| Inputs | ||
| ------ | ||
|
|
||
| **factors.yaml** declares only the factors that were varied (and the continuous ranges that | ||
| were swept). Outcomes are not declared here — they're selected at analysis time (see below): | ||
|
|
||
| .. code-block:: yaml | ||
|
|
||
| factors: | ||
| light_intensity: | ||
| type: continuous | ||
| range: [[0.0, 5000.0]] # the swept range; inferred from the data's min/max if omitted | ||
| table_material: | ||
| type: categorical | ||
| choices: [oak, walnut, bamboo] | ||
|
|
||
| **episode_summary.jsonl** is produced by the eval runner — one JSON object per episode. It | ||
| carries every measured outcome; the analysis picks which one(s) to condition on: | ||
|
|
||
| .. code-block:: json | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
|
|
||
| {"job_name": "pi0_sweep", "episode_idx": 0, | ||
| "arena_env_args": {"light_intensity": 3200.0, "table_material": "oak"}, | ||
| "outcomes": {"success": 1}} | ||
|
|
||
| Choice of estimator | ||
| ------------------- | ||
|
|
||
| ``SensitivityAnalyzer`` picks the estimator from the schema automatically: | ||
|
|
||
| .. list-table:: | ||
| :header-rows: 1 | ||
| :widths: 25 25 50 | ||
|
|
||
| * - Schema | ||
| - Estimator | ||
| - Notes | ||
| * - Any categorical factor | ||
| - MNPE | ||
| - Mixed density estimator; handles continuous + categorical factors together. | ||
| * - All continuous factors | ||
| - NPE | ||
| - Restricts to a Gaussian on a single factor, so a meaningful continuous-only | ||
| analysis needs at least two continuous factors. | ||
|
|
||
| Continuous factors are normalised to ``[0, 1]`` before fitting and de-normalised when | ||
| sampling, so factors on very different scales (e.g. light in the thousands, an offset in | ||
| the hundredths) train on equal footing. Outcomes are binary (0/1); the default query | ||
| conditions on success (1). | ||
|
|
||
| Running a report | ||
| ---------------- | ||
|
|
||
| Point the report generator at a ``(factors.yaml, episode_summary.jsonl)`` pair. The output | ||
| format follows the file extension (``.png``, ``.pdf``, …); reports are written under | ||
| ``eval/`` by default. | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| python -m isaaclab_arena.analysis.sensitivity.generate_report \ | ||
| --factors_yaml factors.yaml \ | ||
| --episode_summary episode_summary.jsonl \ | ||
| --outcome success \ | ||
| --output eval/sensitivity_report.png | ||
|
|
||
| ``--outcome`` selects which per-episode outcome(s) to condition on (keys in the rows' | ||
| ``outcomes`` block); it defaults to ``success``. Pass ``--observation`` to set the value | ||
| per outcome — since outcomes are binary, use ``1`` for success or ``0`` for failure; it | ||
| defaults to ``1`` (success). | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
|
|
||
| Trying it on synthetic data | ||
| --------------------------- | ||
|
|
||
| A synthetic simulator with a *known* ground truth lets you run the whole pipeline on CPU, | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| without Isaac Sim — useful for seeing the output shape and for validating the toolbox | ||
| (the recovered posterior should reflect the planted relationship): | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| # mixed: three continuous + two categorical factors (MNPE) | ||
| python -m isaaclab_arena.analysis.sensitivity.synthetic --kind mixed --output eval/demo.png | ||
|
|
||
| ``--kind`` also accepts ``continuous`` (continuous-only factors, which exercises the NPE path). | ||
|
|
||
| Reading the output | ||
| ------------------ | ||
|
|
||
| .. todo:: | ||
|
|
||
| Add a sample report figure here and walk through reading it. | ||
|
|
||
| Each panel is the posterior over one factor *conditioned on success* — "given the policy | ||
| succeeded, which values of this factor were responsible?" For a continuous factor, mass | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| concentrated at one end of its range means success favoured that end (e.g. a curve rising | ||
| toward bright light → the policy is light-gated). For a categorical factor, the tallest | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| bar is the value most associated with success. | ||
|
|
||
|
cvolkcvolk marked this conversation as resolved.
|
||
| Current scope | ||
| ------------- | ||
|
|
||
| - Outcomes are treated as **binary** (0/1). Conditioning defaults to success; a continuous | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
| outcome is rejected with a clear error rather than silently averaged. | ||
| - Continuous **vector** factors (``dim > 1``) are reserved for a future extension. | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| - The estimators run on CPU and do not require Isaac Sim, so a report can be generated | ||
| anywhere the evaluation JSONL is available. | ||
| - The analysis assumes the ``episode_summary.jsonl`` is a single coherent slice — one | ||
| policy, task, and embodiment. **TODO:** add a filter (in the spirit of robolab's | ||
| ``--filter-policy`` / ``--filter-task``) to select that slice from a larger JSONL, | ||
| rather than relying on the caller to pre-filter it. | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,3 +91,4 @@ More details | |
| :maxdepth: 1 | ||
|
|
||
| concept_evaluation_types | ||
| concept_sensitivity_analysis | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). | ||
| # All rights reserved. | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 |
|
cvolkcvolk marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). | ||
| # All rights reserved. | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| # Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). | ||
| # All rights reserved. | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import torch | ||
|
|
||
| from sbi.inference import MNPE, NPE | ||
| from sbi.utils import BoxUniform | ||
|
|
||
| from isaaclab_arena.analysis.sensitivity.dataset import SensitivityDataset | ||
|
|
||
|
|
||
| class SensitivityAnalyzer: | ||
| """Fits a neural posterior over all factors, conditioned on all outcomes. | ||
|
|
||
| Picks the sbi estimator from the schema: | ||
|
|
||
| - MNPE when any factor is categorical (it handles mixed continuous + categorical theta). | ||
| - NPE when every factor is continuous. | ||
|
|
||
| It then trains on the full (theta, x) and samples the joint posterior at a chosen | ||
|
cvolkcvolk marked this conversation as resolved.
Outdated
|
||
| observation. The single observation conditions on *all* outcome columns at once, so a | ||
| query like "which factors produced success?" is answered for every factor jointly. | ||
|
|
||
| Continuous factors are normalized to [0, 1] before fitting and denormalized when | ||
| sampling, so factors on very different scales (e.g. light in thousands, an offset in | ||
| hundredths) train on equal footing. Categorical columns keep their integer codes. | ||
| """ | ||
|
|
||
| def __init__(self, dataset: SensitivityDataset): | ||
| self.dataset = dataset | ||
| self.posterior = None | ||
| continuous_factors = [factor for factor in dataset.schema.factors if factor.type == "continuous"] | ||
| self._num_continuous = len(continuous_factors) | ||
| self._continuous_low = torch.tensor([factor.range[0][0] for factor in continuous_factors]) | ||
| self._continuous_high = torch.tensor([factor.range[0][1] for factor in continuous_factors]) | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
|
|
||
| def _select_inference_class(self): | ||
| """Choose the sbi inference class for this schema. | ||
|
|
||
| Returns MNPE when any factor is categorical (its mixed density estimator handles | ||
| continuous + categorical theta together), and NPE when every factor is continuous. | ||
| """ | ||
| return MNPE if self.dataset.has_categorical_factors else NPE | ||
|
|
||
| def _normalized_prior(self): | ||
| """Uniform prior matching the normalized theta: continuous dims [0, 1], categoricals [0, k-1].""" | ||
| low_bounds = [0.0] * self._num_continuous | ||
| high_bounds = [1.0] * self._num_continuous | ||
| for factor in self.dataset.schema.factors: | ||
| if factor.type == "categorical": | ||
| low_bounds.append(0.0) | ||
| high_bounds.append(float(len(factor.choices) - 1)) | ||
| return BoxUniform(low=torch.tensor(low_bounds), high=torch.tensor(high_bounds)) | ||
|
cvolkcvolk marked this conversation as resolved.
cvolkcvolk marked this conversation as resolved.
|
||
|
|
||
| def _normalize(self, theta: torch.Tensor) -> torch.Tensor: | ||
| """Scale the continuous (leading) theta columns to [0, 1]; leave categoricals untouched.""" | ||
| normalized = theta.clone() | ||
| span = (self._continuous_high - self._continuous_low).clamp_min(1e-12) | ||
| normalized[:, : self._num_continuous] = (theta[:, : self._num_continuous] - self._continuous_low) / span | ||
| return normalized | ||
|
|
||
| def _denormalize(self, theta: torch.Tensor) -> torch.Tensor: | ||
| """Inverse of _normalize: map the continuous columns back to their original ranges.""" | ||
| denormalized = theta.clone() | ||
| span = self._continuous_high - self._continuous_low | ||
| denormalized[:, : self._num_continuous] = theta[:, : self._num_continuous] * span + self._continuous_low | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
| return denormalized | ||
|
|
||
| def fit(self, training_batch_size: int = 50): | ||
|
cvolkcvolk marked this conversation as resolved.
|
||
| """Train the estimator on the full (theta, x); store and return the fitted posterior.""" | ||
| print( | ||
| f"[INFO] SensitivityAnalyzer: fitting {self._select_inference_class().__name__} on" | ||
| f" {self.dataset.num_episodes} episodes" | ||
| f" (theta dim={self.dataset.theta.shape[1]}, x dim={self.dataset.x.shape[1]})." | ||
| ) | ||
| inference = self._select_inference_class()(prior=self._normalized_prior()) | ||
| inference.append_simulations(self._normalize(self.dataset.theta), self.dataset.x) | ||
| density_estimator = inference.train(training_batch_size=training_batch_size) | ||
| self.posterior = inference.build_posterior(density_estimator) | ||
| return self.posterior | ||
|
|
||
| def sample_posterior(self, observation: torch.Tensor | None = None, num_samples: int = 5000) -> torch.Tensor: | ||
| """Sample the joint posterior over all factors at observation. | ||
|
|
||
| Defaults to the dataset's default observation (condition on success). Returns a | ||
| (num_samples, total_factor_dim) tensor laid out like theta — continuous columns first | ||
| (in original, denormalized units), then integer-coded categorical columns. | ||
| """ | ||
| assert self.posterior is not None, "Call fit() before sampling the posterior" | ||
| if observation is None: | ||
| observation = self.dataset.default_observation() | ||
| with torch.no_grad(): | ||
| normalized_samples = self.posterior.sample((num_samples,), x=observation) | ||
| return self._denormalize(normalized_samples) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.