Metadata-Version: 2.5
Name: fidgate-sdk
Version: 1.16.1
Summary: FIDgate SDK — standalone, curated public data-model surface
Project-URL: Homepage, https://fidgate.com
Author: FIDgate
License-Expression: MIT
License-File: LICENSE
Keywords: battery,bess,energy,fidgate,ppa,project-finance,renewables,sdk,solar
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: numpy>=1.24
Requires-Dist: pyarrow>=16.0
Requires-Dist: pydantic<3.0,>=2.5
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff==0.15.15; extra == 'dev'
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
Description-Content-Type: text/markdown

# fidgate-sdk

A standalone, stable **public data-model surface** for FIDgate clients: the DTOs,
inter-BC contracts, configuration schemas, and value types that cross the system
boundary — and nothing else (no services, ports, adapters, or domain logic).

`fidgate-sdk` is **fully independent**: it carries its own standalone copies of the
data models and has **no dependency on `fidcore`** in either direction. Installing
the SDK pulls in only `numpy`, `pydantic`, `httpx` (the HTTP client), `pyyaml`
(config-document validation and serialization) and `pyarrow` (rendering the run's
Parquet result tables to CSV), never core's logic. The copies are
**generated from the FIDgate engine's own model sources** and checked for drift on
every release, so they never diverge by hand.

## Install

```bash
pip install fidgate-sdk
# or
uv add fidgate-sdk
```

Requires Python >= 3.10. The only runtime dependencies are `numpy`, `pydantic`,
`httpx`, `pyyaml` and `pyarrow`.

## Usage

```python
from fidgate_sdk import AssetConfigSchema, ProjectResult, ProjectSnapshot
# or browse by category:
from fidgate_sdk.models import config, contracts, results, types
```

These schemas are independent copies, **not** the same classes as `fidcore`'s
(`fidgate_sdk.AssetConfigSchema is fidcore...AssetConfigSchema` is `False`). The
engine's entry points validate **dict payloads** internally
(`AssetConfigSchema.model_validate(...)`), so the supported path is to pass plain
dicts — the SDK schemas are for typing, construction, and validation on the client
side, and serialise to the dicts the engine accepts.

### Version configs: typed models or raw YAML

`create_version` / `update_version` accept a config as a **path / raw bytes**
(sent verbatim — comments survive), a **wire-form dict**, or a typed
**`ProjectConfig`** bundle the SDK serialises to YAML itself:

```python
from fidgate_sdk import AssetConfigSchema, FidgateClient, ProjectConfig, ProjectTimeline

config = ProjectConfig(
    timeline=ProjectTimeline(construction_start_year=2024,
                             operation_start_year=2025, operation_end_year=2044),
    asset=AssetConfigSchema.model_validate({...}),   # models or plain dicts per section
    commercial={...},                                # dicts pass through verbatim
)
with FidgateClient() as client:
    client.create_version(project_id, config, data_dir="caso_01/")
```

Every flavor is **schema-validated locally before the request** against
`fidgate_sdk.config_document.SECTION_SCHEMAS` — the same models the API
enforces — raising `ConfigValidationError` (no request issued) on failure;
`external_data` file references are tolerated anywhere, since the engine
resolves them before validating sections. Pass `validate=False` to skip the
local check and let the API decide (the escape hatch when an older SDK meets a
newer server schema).

### Fluent builder

`ProjectBuilder` assembles that document step by step — each call validates its
piece immediately, and the materialized document is **sparse** (unset optionals
are truly absent, so defaults stay defaults in the stored YAML). You describe the
project in the vocabulary you built it with; the builder translates that into
what the API and the engine need, so no example here writes a dotted config path
by hand. The builder only assembles: `to_project_config()` hands the result to
the client as a normal `ProjectConfig`, and everything still travels as ONE
`create_version` call:

```python
from fidgate_sdk import FidgateClient, ProjectBuilder, constant_price, price_series_csv

builder = (
    ProjectBuilder("planta_b")
    .set_timeline(construction_start_year=2024,
                  operation_start_year=2025, operation_end_year=2044)
    .set_grid_connection(max_injection_mw=40.0)
    .add_solar("PV_1", rated_capacity_mw=30.0, profile_path="data/pv_profile.csv",
               capex_eur=18_000_000, opex_eur=300_000)
    .add_merchant_market("m_da", price=price_series_csv("data/da_prices.csv"))
    .add_ppa("ppa_1", price=constant_price(55.0), percent=70.0, term_years=15)
    .set_financial(discount_rate_project_pct=7.0, discount_rate_equity_pct=9.0)
)
with FidgateClient() as client:
    version = client.create_version(project_id, builder.to_project_config(),
                                    data_dir="caso_01/")
```

`add_merchant_market` declares the market **and** the plant's merchant exposure
on it in one call (the pair `add_market` + `add_market_exposure` used to spell) —
the engine never infers an exposure, so the declaration stays explicit. Call
order does not matter: a valid program emits the same document however its calls
are ordered, and a component added before `set_timeline`, or an exposure naming a
market declared later, resolves at `to_project_config()`.

**Sweeps and scenarios in builder vocabulary.** A `project_optimization` batch
varies a knob by the id of the thing it belongs to and the kwarg it was built
with — no config path, and a typo fails locally in `to_project_config()`, never
after upload:

```python
from fidgate_sdk import ProjectBuilder, debt, per_mw_cost, span

builder.add_kpis("npv_eur", "project_irr", "equity_irr", "lcoe_eur_mwh", "dscr_min")
builder.sweep("BESS_1", unit_duration_h=[2.0, 4.0], capex=[420_000, 700_000])  # one axis, zipped
builder.sweep_financial(leverage_pct=span(50.0, 70.0, 3))                       # a separate axis
builder.scenario("ppa_65", ppa_1={"price": 65.0})                               # one explicit variant
```

Kwargs in one `sweep` call are linked (zipped); separate calls multiply. The two
id-less sections have typed helpers, `sweep_financial` and
`sweep_grid_connection`; `scenario` reaches the same knobs by target. Debt,
depreciation, contract commitments and maintenance windows are typed factories
(`debt(...)`, `depreciation(...)`, `baseload_commitment(...)`,
`maintenance_window(...)`), and `builder.check()` returns a report of what is
still missing, what defaulted, what a sweep auto-declared and the batch's scenario
count, without ever raising. `builder.sweepable()` lists the sweepable knobs each
declared target currently carries. `lcoe_eur_mwh` is the generation's LCOE only
(`None` without generation components or generated energy), `lcos_eur_mwh` the
storage's (`None` without storage or discharge, or when the battery is tolled
out), and the IRRs are `nan` unless the cashflow has exactly one.

Every section the engine accepts is reachable. A physics-modelled PV plant
sharing its site with a tolled battery reads:

```python
from fidgate_sdk import (
    ProjectBuilder, area_sizing, cec_inverter, cec_module, constant_price,
    per_mw_cost, price_series_csv, pvgis_tmy_meteo, pvlib_generation, sat_mount,
)

builder = (
    ProjectBuilder("pv_bess_tolling")
    .set_timeline(construction_start_year=2026,
                  operation_start_year=2027, operation_end_year=2051)
    .set_site(lat=52.52, lon=13.405, tz="Europe/Berlin", area_net_ha=100.0,
              meteo=pvgis_tmy_meteo(lat=52.52, lon=13.405, year=2027))
    .set_grid_connection(max_injection_mw=100.0)
    .add_pv("PV_75", relative_area=0.75,
            module=cec_module("data/equipment/cec_modules.csv", "Trina Solar TSM-600NEG19RC.20",
                              length=2.172, width=1.303),
            inverter=cec_inverter("data/equipment/cec_inverters.csv",
                                  "Huawei Technologies Co - Ltd : SUN2000-100KTL-USH0 [800V]"),
            mount=sat_mount(axis_tilt=0.0, axis_azimuth=0.0, max_angle=60.0, backtrack=True,
                            module_nb=2, module_height=1.5, racking_model="open_rack"),
            sizing=area_sizing(modules_per_string=25, strings_per_inverter=8, gcr=0.6),
            generation=pvlib_generation(temperature_model="pvsyst",
                                        temperature_model_parameters={"u_c": 29.0, "u_v": 0.0,
                                                                      "module_efficiency": 0.21,
                                                                      "alpha_absorption": 0.9},
                                        losses_model="no_loss", inv_to_poi_loss_pct=2.0),
            capex=per_mw_cost(600_000), opex=per_mw_cost(12_000), annual_degradation_pct=0.4)
    .add_battery("BESS_1", unit_rated_power_mw=2.5, unit_duration_h=2.0, nb_units=10,
                 capex=per_mw_cost(400_000), opex=per_mw_cost(10_000), lifetime_years=15)
    .set_actors(["owner", "toller"], plant_owner="owner")
    # one call for the market + the toller's exposure; a second exposure adds on
    .add_merchant_market("m_da", price=price_series_csv("data/da_prices.csv"),
                         exposure_id="exp_toller", seller="toller",
                         effective_year=2027, term_years=10)
    .add_market_exposure("exp_owner", market_id="m_da", seller="owner")
    .add_tolling("c_tolling_bess", price=constant_price(60_000.0), ceded_components=["BESS_1"],
                 seller="owner", buyer="toller", term_years=10)
    .add_ppa("c_ppa_solar", price=constant_price(40.0), quantity_reference="PRODUCTION",
             of=["PV_75"], seller="owner", buyer="toller", term_years=10)
    .set_dispatch(seed=7)
    .set_financial(discount_rate_project_pct=7.0, discount_rate_equity_pct=9.0,
                   corporate_tax_rate_pct=20.0)
)
```

The helper families, all exported at the top level:

| Family | Helpers |
|---|---|
| price curves | `constant_price` (a number, 12 or 8760 values), `escalating_price` (`escalation_pct=`, a percent), `price_series_csv`, `exogenous_price_csv` / `exogenous_price` (`fill=`) |
| cost models | `absolute_cost`, `per_mw_cost`, `composite_cost` → a side-agnostic `CostModel` for `capex=` / `opex=` |
| site meteo | `pvgis_tmy_meteo`, `weather_csv_meteo`, `pvgis_tmy` (site-relative) → `set_site(meteo=)` |
| PV hardware / layout | `cec_module`, `module_parameters`, `cec_inverter`, `fixed_mount`, `sat_mount`, `area_sizing`, `topology_sizing`, `pvlib_generation` |
| ageing / availability | `generation_degradation`, `linear_degradation`, `soh_curves_degradation`, `global_availability`, `maintenance_windows`, `maintenance_window` |
| financial pieces | `debt`, `depreciation` → typed factories for `set_financial(debt=…, depreciation=…)` |
| contract commitments / axes | `baseload_commitment`, `as_produced_commitment`, `quantity_reference`, `quantity_arc`, `quantity_expression`, `per_mwh_shortfall_penalty`, `buyback_penalty` |
| value lists for a sweep | `span`, `steps` |

Contracts have one method per type — `add_ppa`, `add_baseload`,
`add_baseload_buyback`, `add_tolling`, `add_custom_contract` — so the rules the
lenient input schema leaves to the engine (a tolling fee accrues per period,
never hourly; a custom contract names its quantity; a financial settlement names
a declared reference market) fail at the call that broke them. A buyback names
its basis explicitly — `add_baseload_buyback(..., buyback_basis=)` /
`buyback_penalty(market_id, basis=)` with `"fixed_cost"` (buy − strike, signed)
or `"replacement_cost"` (max(0, buy − strike)); there is no default. Markets take
`market_type="DA" | "ID" | "imbalance"`, and a project declares at most one market
for now (F-17 — a second market id raises; more exposures on it are fine);
`set_dispatch(...)` writes the optional `dispatch:` section;
`set_financial(loss_treatment=...)` qualifies the tax block.
Every helper returns a plain dict or a small frozen dataclass, and every slot
that takes one also takes the raw `{model_name, model_parameters}` node.

### Assisted wizard

Don't know the builder API yet? `ProjectBuilder.assisted()` asks the questions
instead — main setup first (solar merchant, solar + PPA, solar + battery + PPA,
custom, or a physics-modelled PV plant with a battery under a tolling agreement),
then only what that setup needs, with optional extras as skippable follow-ups
(Enter omits the key, so engine defaults apply at run time). The custom setup
additionally offers a physics PV component (with the site's weather source), a
wind component, tolling and baseload contracts, an actor registry and the
dispatch knobs, each behind a yes/no gate. Its optimization step builds a menu of
the plant's sweepable knobs (from `ProjectBuilder.sweepable()`) and asks explicit
scenarios by target and knob — never a dotted config path:

```python
from fidgate_sdk import FidgateClient, ProjectBuilder

config = ProjectBuilder.assisted()          # interactive Q&A in the terminal
with FidgateClient() as client:
    version = client.create_version(project_id, config, data_dir="caso_01/")
```

The wizard always returns the validated `ProjectConfig`; choosing the
"YAML file" delivery additionally writes it to a path of your choice. IO is
injectable (`input_fn` / `output_fn`), so the whole flow can be scripted —
see `fidgate_sdk.wizard.run_wizard`.

`ParameterSweep` is a different thing from `ProjectBuilder.sweep`: it is a **client-side loop** that stores and runs one version per value
over the built document — one version, one run each, headline KPIs (`feasible`,
`irr`, `npv_eur`, `energy_to_grid_mwh`) extracted into plain dict rows — whereas
`builder.sweep(...)` declares ONE server-side `project_optimization` batch. As a
raw client-side loop it addresses its parameter by config path:

```python
from fidgate_sdk import ParameterSweep

sweep = ParameterSweep(
    parameter="commercial.contracts.ppa_1.pricing.model_parameters.price",
    values=[45.0, 55.0, 65.0],
)
rows = sweep.run(client, project_id, builder, into="sweep_results", data_dir="caso_01/")
```

### Reading responses: JSON or text

Every object the client returns is a typed model (`version.id` keeps working)
that prints as its JSON body; ask for `.to_text()` when you want something
readable. Listings are ordinary lists with the same views:

```python
version = client.get_version(project.id, version.id)
print(version)                           # indented JSON; version.to_dict() / .to_json() too
print(version.to_text())                 # one summary line + indented file table
print(client.list_projects().to_text())  # NAME  CREATED  UPDATED  ID table
```
