Third-Party Module Wrapping#
Status: Proposed — not yet implemented Tracked by: #11 Owner: BREOS maintainers
Problem#
BREOS imports several third-party libraries directly throughout the package
(pvlib, pandas, numpy, scipy, numba, rainflow, geopy,
openmeteo-requests, timezonefinder, pymoo,
requests-cache, matplotlib, openpyxl).
Direct usage means those libraries co-own the BREOS public API. When they
change — and pvlib in particular has historically broken APIs across
minor releases — every call site has to be updated and every consumer of
BREOS may break. Concretely today:
pvlib.Location,pvlib.PVSystem, andpvlib.irradiance/pvlib.iam/pvlib.temperature/pvlib.ivtools/pvlib.pvsystem/pvlib.inverter/pvlib.iotoolsare referenced from at least 5 modules.Locationfrompvlib.locationappears in the public signatures ofsolar.calculate_pv_production_dc,solar.calculate_pv_production_ac,solar.calculate_multi_array_production,app, andoptimization. Anyone calling BREOS must construct apvlib.Locationthemselves.rainflowis imported inbattery.py,scipy.optimize.minimize_scalarinoptimization.py,scipy.optimize.differential_evolutionin a tooling script, andscipy.interpolate.Akima1DInterpolatorinweather.py.
Goal#
Own the BREOS public API. Concentrate every third-party touchpoint in a small adapter layer so that:
Library upgrades (e.g.
pvlib0.14 → 0.15) require changes in a single place rather than across the package.The BREOS surface area stays narrow and domain-specific (we use ~10 pvlib calls; we don’t need to expose pvlib’s hundreds of routines).
Alternative implementations (e.g. swap
scipy.optimize.minimizefor a different solver, swappvlibfor a future in-house model) become a single-class change.
Pattern#
For each wrapped concept, introduce:
# breos/adapters/location.py
from abc import ABC, abstractmethod
class SolarPosition: ... # plain BREOS dataclass
class Location(ABC):
@abstractmethod
def solar_position(self, times: TimeIndex) -> SolarPosition: ...
class PvlibLocation(Location):
def __init__(self, latitude, longitude, tz, altitude=0):
import pvlib
self._inner = pvlib.location.Location(latitude, longitude, tz, altitude)
def solar_position(self, times: TimeIndex) -> SolarPosition:
df = self._inner.get_solarposition(times=times.to_pandas())
return SolarPosition.from_pvlib(df)
Core BREOS modules depend only on the abstraction (Location). Concrete
implementations (PvlibLocation) are instantiated at the system boundary
(CLI, app.py, tests) and injected.
# breos/cli.py (composition root)
location: Location = PvlibLocation(lat, lon, tz)
run_simulation(location, ...)
Scope and phases#
Wrapping every dependency at once is a multi-week refactor. Recommended phasing, ordered by API-churn risk × surface area:
Phase 1 — pvlib (highest priority)#
Narrow surface (~10 calls), high churn risk, already shows up in public signatures.
Concept |
Wrapped in |
Replaces |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Phase 2 — small scientific deps#
rainflow→breos.adapters.cycle_counting(used inbattery.py).scipy.optimize.minimize_scalar→breos.adapters.optimizer(used inoptimization.py).scipy.interpolate.Akima1DInterpolator→breos.adapters.interpolation(used inweather.py).numba.jit/prange→ keep direct (perf-critical, tightly coupled to kernel internals; abstracting adds no portability value).
Phase 3 — IO / external services#
openmeteo-requests,requests-cache→breos.adapters.weather_client.geopy,timezonefinder→breos.adapters.geo.pymoo→breos.adapters.multi_objective(used inoptimization.py).openpyxl,pyarrow→ keep in validation/export-specific paths.
Out of scope: pandas and numpy#
pandas.DataFrame, pandas.Series, numpy.ndarray are treated as data
primitives, not wrapped. Rationale:
They are the lingua franca of the scientific Python ecosystem; wrapping them would force every caller to convert at the boundary.
Their APIs are stable across years.
The cost (rewriting every module + every test + every example) far exceeds the insulation benefit.
If we later want stronger schema guarantees, introduce typed wrappers at
specific boundaries (e.g. a WeatherFrame dataclass that validates
columns) rather than a global abstraction.
matplotlib is also kept direct inside plotting.py, but it is packaged as
an optional plots extra because plotting is not a load-bearing core
dependency.
Migration mechanics#
Create
breos/adapters/package, one module per concept.Add the abstraction + the
Pvlib*(etc.) implementation side by side.Migrate call sites one module at a time. Each migration is a small PR.
Add a
ruffrule (or simple CI grep) that blocks new direct imports of wrapped libraries outsidebreos/adapters/.Update tests to inject fakes via the abstraction instead of patching
pvlib.Once all call sites are migrated, document the public API as the adapter layer.
Risks and non-goals#
Performance: the wrapper layer must not introduce per-timestep overhead. Keep wrappers thin — pass arrays through, do not iterate.
Test churn: existing tests import
pvlib.Locationdirectly. They will need to construct via the adapter or via a fake. Plan one PR per test module to spread the cost.Not a full hexagonal rewrite. This is API insulation, not a port + adapter restructure of the whole package. Domain logic stays where it is; only the dependency direction at the edges changes.
Not a replacement of pvlib. We continue to use it; we just stop exposing it.
Effort estimate#
Phase 1 (pvlib): ~1–2 weeks of focused work, 5–7 PRs.
Phase 2 (scipy / rainflow): ~2–3 days, 2–3 PRs.
Phase 3 (IO / external services): ~1 week, 4–5 PRs.
Total: ~3–4 weeks if pursued continuously; more realistic as a background refactor over a couple of months.