Skip to content

Code Map

This page maps the code of SolarFields: its packages, how they depend on each other, and the main call paths of a calculation. It was derived from the knowledge graph of the repository (GitNexus, October 2026). The physical models, the design decisions and the limitations are described in Architecture; this page doesn't repeat them.

Overview

SolarFields computes the steady state of a solar thermal collector field at one operating point: the flow distribution down to every riser of every collector, the pressures and temperatures of the whole field network, the heat output and the pressure drops.

A calculation runs in four stages:

  1. Input. A YAML configuration (or Python objects) describes the field: collectors, strings, sub-fields, pipes, valves, the fluid, the operating conditions and the inlet. io/loaders.py reads it strictly and builds the components.
  2. Network. Every field format becomes one FieldNetwork: nodes and edges, where an edge is a pipe, a valve or a string.
  3. Solution. FieldSolver couples the string solver (collector physics, riser flows) with the own network solver (node-pressure Newton method) in an outer loop, and carries the temperatures along the flow, including the heat losses of the pipes.
  4. Results. A FieldResult mirrors the component hierarchy: field, sub-fields, strings, collectors, pipes and valves, nodes.

Architecture Diagram

flowchart TD
    subgraph Input["Input"]
        YAML["Config files<br/>configs/*.yaml, examples/*.yaml"]
        API["Python API"]
    end

    subgraph IO["io"]
        LOAD["loaders.py<br/>load_config: strict keys and names"]
        REN["renamed.py<br/>renamed and replaced keys"]
    end

    subgraph COMP["components"]
        COLL["Collector, CollectorString, Pipe"]
        FIELD["CollectorField<br/>to_network, add_to_network"]
        NET["FieldNetwork<br/>nodes and edges<br/>from_subfields, BranchPipe"]
    end

    subgraph SOLV["solvers"]
        FS["FieldSolver<br/>outer loop"]
        SS["StringSolver"]
        PP["PandapipesSolver<br/>reference, optional"]
        RA["result_assembly.py<br/>FieldResult"]
    end

    subgraph CORE["core: numerics and physics"]
        NN["network_newton.py<br/>node-pressure Newton, temperatures"]
        DB["dominant_branch.py<br/>riser flows"]
        TH["thermal.py<br/>ISO 9806 collector model"]
        HL["heat_loss.py<br/>pipe heat losses"]
        FR["friction.py<br/>Churchill friction, K values"]
        FL["fluid_loader.py, fluid_properties.py<br/>temperature-dependent properties"]
        OC["operating_conditions.py"]
    end

    subgraph OUT["Output"]
        RES["results<br/>FieldResult, SubfieldResult,<br/>StringResult, PipeResult"]
        PLOT["plotting"]
    end

    YAML --> LOAD
    REN --> LOAD
    LOAD --> COLL
    LOAD --> FIELD
    LOAD --> NET
    LOAD --> OC
    API --> COLL
    API --> FIELD
    FIELD --> NET
    NET --> FS
    NET --> PP
    FS --> SS
    FS --> NN
    PP --> SS
    PP --> NN
    SS --> DB
    SS --> TH
    SS --> HL
    NN --> HL
    NN --> FR
    DB --> FR
    SS --> FL
    NN --> FL
    FS --> RA
    PP --> RA
    RA --> RES
    RES --> PLOT

PandapipesSolver solves the same networks with pandapipes and serves as reference for tests and benchmarks; pandapipes is an optional dependency, imported on first use.

Functional Areas

Package Role Main modules
io Reading configurations: every section is checked, unknown keys and undefined names are errors; renamed keys raise an error that names the replacement. loaders.py (load_config, load_collector, load_string, load_field), renamed.py
components The field model: collectors (harp, meander), strings of collectors in series, sub-fields with headers, and the network of the whole field. collector.py, collector_string.py, pipe.py, field.py (CollectorField, FieldPiping), network.py (FieldNetwork and its from_subfields, BranchPipe, NetworkEdge), pipe_systems.py (which values of buried_pipes and pipes_in_air the pipes take, #74)
solvers The solution of a string and of a field, and the assembly of the results. string_solver.py (StringSolver), field_solver.py (FieldSolver, solve_field_from_yaml), result_assembly.py (the FieldResult, with each string's share of the flow, flow_shares), pandapipes_solver.py and network_builder.py (reference)
core Numerics and physics, mostly compiled with numba: network solution, riser flow distribution, collector thermal model, pipe friction and heat losses, fluid properties, operating conditions. network_newton.py, dominant_branch.py, thermal.py, heat_loss.py, friction.py, fluid_loader.py, fluid_properties.py, fluid_registry.py, header_design.py (header sizes for velocity limits), pipe_materials.py and pipe_catalogue.py (pipe materials and sizes), operating_conditions.py, constants.py, units.py, yaml_files.py (reading YAML files), quantities.py (units of the inputs), input_checks.py (ranges of the inputs), frozen.py (read-only lists and mappings of the frozen components)
results Result dataclasses that mirror the component hierarchy. field_result.py, subfield_result.py, headers_result.py, string_result.py, collector_result.py, pipe_result.py, beyond_data.py
plotting Figures of string and field results, with the limits of VelocityLimits (highest velocities, lowest riser Reynolds number). strings.py, field.py, composite.py, common.py (VelocityLimits)
data Fluid property coefficients and tables (generated offline from CoolProp). fluid_coefficients/, fluid_metadata.yaml, tables/

Outside the package: configs/ holds the component libraries (collectors, pipes), examples/ the worked examples and the benchmarks, scripts/ the generation of the fluid tables and of the API reference pages, and tests/ the unit and integration tests.

Key Execution Flows

The traces below come from the knowledge graph; each step calls the next.

1. Field from a configuration file

solve_field_from_yaml → FieldSolver.solve → solve_flows → residuals → _edge_characteristics → pressure_drop_slope → churchill_friction_slope → churchill_friction

solve_field_from_yaml loads the configuration with load_config, which builds the FieldNetwork for every field format (one sub-field, several sub-fields with branch pipes, or a graph). FieldSolver.solve then runs the outer loop:

  1. Each string enters the network through a characteristic Δp(ṁ), fitted to string solves.
  2. solve_flows (core/network_newton.py) solves the network for the flows of all edges and the pressures of all nodes. Each Newton step evaluates the pressure drop of every edge and its slope (_edge_characteristics), with the Churchill friction factor and the K values (core/friction.py). check_string_flows rejects a string with reverse or no flow (both field solvers, #71).
  3. propagate_temperatures carries the temperatures along the flow, solves every string at its flow and inlet temperature, applies the heat losses of the pipes (core/heat_loss.py) and mixes the flows where they join by enthalpy (mix_temperature, #71). The given pressure is taken at the field's height (pressure_at_field_height), with the density of the column at the last pass's outlet temperature on the safe side (column_temperature).
  4. The loop stops when every string's pressure drop matches the network and the temperatures no longer change; result_assembly.field_result builds the FieldResult, and check_field_result (core/fluid_limits.py) checks it against the temperature limits, for a field below the point of the given pressure its outlet at that height too (places_above_field).

2. Minor losses in the network solution

solve_field → FieldSolver.solve → solve_flows → residuals → _edge_characteristics → pressure_drop_slope → pressure_drop_k_factor

The same Newton step, for the K values of fittings, valves and T-junctions: each loss is evaluated with its own K value and diameter, so that pipe friction and minor losses stay separate in the results.

3. String with pipe heat losses

solve_string → StringSolver.solve → solve_mass_flow → _pipe_result → Pipe.outlet_temperature → pipe_outlet_temperature

StringSolver solves the collectors of a string in series: the ISO 9806 thermal model of every riser strip of each collector (solve_thermal_risers in core/thermal.py; one model per collector with a single flow path or with riser_temperatures off), the riser flow distribution inside it (flow 4), and the pipes between and around the collectors with the exact solution of their heat losses (core/heat_loss.py). The temperature loop repeats until the collector and riser temperatures no longer change; each pass solves the riser flows with the fluid properties at the riser temperatures of the pass before (StringSolver._riser_pass). A collector or riser whose thermal model returns no number (no solution, or the iteration with a8 didn't converge) raises CollectorEquationError right there (StringSolver._equation_error, #70).

4. Riser flow distribution of a collector

riser_flows → _solve_riser_flows → _jacobian → pressure_drop_slope → pressure_drop_pipe → reynolds_number

The dominant-branch method (core/dominant_branch.py) solves the flows of all risers of a harp or meander collector at once. Its Jacobian uses the exact slope of every loss term (pipe friction with the local slope of the Churchill friction factor, T-junction and bend losses), so that each step is a Newton step. The elements of a collector's riser paths (harp_elements) are built once per collector; every element has the fluid properties at its own temperature (element_temperatures, get_fluid_props_within_limits_array). The paths count as balanced when they agree within 1 % of tol_dp and within 0.01 % of the collector's pressure drop (RISER_TOL_RELATIVE, #70). dominant_branch_harp is the same solve with one density and viscosity for all elements.

5. Fluid properties

main (string benchmark) → run_single_benchmark → StringSolver.solve → solve_mass_flow → _pipe_result → get_fluid_props → load_shipped_coefficients → load_metadata

Every element evaluates the density, viscosity and heat capacity of the fluid at its own temperature, and for water at its own absolute pressure. core/fluid_loader.py loads the fitted formulas of the fluid and concentration from data/ once and caches them; core/fluid_properties.py evaluates them (compiled with numba). scripts/generate_fluid_tables.py fits the formulas to CoolProp's data; CoolProp isn't needed at run time. core/fluid_limits.py gives the limits, freezing below and boiling at the local pressure above: the solvers take the properties at a limit during their iterations (get_fluid_props_within_limits) and check their converged results (check_string_result, check_field_result). A converged result beyond these limits raises TemperatureOutOfRangeError, and so does a thermal oil below the range of its vapour pressure curve (about 0.5 bar) or a temperature or pressure that isn't a number (#69). The same check lists the places beyond the fluid's data, the range its formulas are fitted to (data_limits), in the result's beyond_fluid_data; with SolverParams.allow_fluid_extrapolation off, it raises there too (#69).

Where to Start

  • A new configuration key: io/loaders.py (the known keys of a section come from the dataclass fields where the section maps onto one), the component in components/, a test in tests/unit/test_config_keys.py.
  • A change of the physics: core/ and the description in docs/architecture.md, in the same commit.
  • A new result value: results/ and solvers/result_assembly.py, so that both field solvers give it.