technologydata 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,503 @@
1
+ # SPDX-FileCopyrightText: technologydata contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Growth models for projecting technology parameters over time."""
6
+
7
+ import inspect
8
+ import logging
9
+ import typing
10
+ from abc import abstractmethod
11
+ from collections.abc import Callable
12
+ from typing import Annotated, Self
13
+
14
+ import numpy as np
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+ from scipy.optimize import curve_fit
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class GrowthModel(BaseModel):
22
+ """
23
+ Abstract base for growth models used in projections.
24
+
25
+ To implement a new growth model that inherits from this class, the following must be provided:
26
+ 1. A mathematical function representing the growth model by implementing the abstract method `function(self, x: float, **parameters) -> float`.
27
+ The parameters of the function (besides `x`) will be automatically detected and used for fitting and projection.
28
+ 2. The parameters should be defined as attributes of the class, initialized to `None` if they are to be fitted.
29
+
30
+ pydantic configuration:
31
+ - `validate_assignment = True`: Ensures that any assignment to model attributes is validated,
32
+ as growth models may be created first with missing parameters (set to None) and then fitted later
33
+ and data points may be added after creation.
34
+ """
35
+
36
+ model_config = ConfigDict(validate_assignment=True)
37
+
38
+ data_points: Annotated[
39
+ list[tuple[float, float]],
40
+ Field(description="Data points (x, y) for fitting the model, where f(x) = y."),
41
+ ] = list()
42
+
43
+ @abstractmethod
44
+ def function(self, *args: typing.Any, **kwargs: typing.Any) -> float | np.ndarray:
45
+ """Represent the growth model function."""
46
+ pass
47
+
48
+ @property
49
+ def model_parameters(self) -> list[str]:
50
+ """Return the set of model parameters that have been provided (are not None)."""
51
+ return [f for f in type(self).model_fields.keys() if f != "data_points"]
52
+
53
+ @property
54
+ def provided_parameters(self) -> list[str]:
55
+ """Return the set of model parameters that have been provided (are not None)."""
56
+ return list(
57
+ self.model_dump(
58
+ include=set(self.model_parameters), exclude_none=True
59
+ ).keys()
60
+ )
61
+
62
+ @property
63
+ def missing_parameters(self) -> list[str]:
64
+ """Return the set of model parameters that are missing (have not been provided)."""
65
+ return [p for p in self.model_parameters if p not in self.provided_parameters]
66
+
67
+ def add_data(self, data_point: tuple[float, float]) -> Self:
68
+ """Add a data point to the model for fitting."""
69
+ self.data_points.append(data_point)
70
+ return self
71
+
72
+ def project(
73
+ self,
74
+ to_year: int,
75
+ ) -> float:
76
+ """
77
+ Project using the model to the specified year.
78
+
79
+ This function uses the parameters that have been provided for the model either directly or through fitting to data points.
80
+
81
+ Parameters
82
+ ----------
83
+ to_year : int
84
+ The year to which to project the values.
85
+
86
+ Returns
87
+ -------
88
+ float
89
+ The projected value for the specified year.
90
+
91
+ """
92
+ if len(self.missing_parameters) > 0:
93
+ raise ValueError(
94
+ f"Cannot project. The following parameters have not been specified yet and are missing: {self.missing_parameters}."
95
+ )
96
+
97
+ return self.function(
98
+ to_year, **self.model_dump(include=set(self.provided_parameters))
99
+ )
100
+
101
+ @classmethod
102
+ def _kwpartial(
103
+ cls, f: Callable[..., float], **fixed_params: dict[str, float]
104
+ ) -> Callable[..., float]:
105
+ """
106
+ Like functools.partial, but for keyword arguments.
107
+
108
+ Enables us to wrap a function in a way that is compatible with scipy.optimize.curve_fit,
109
+ which does not play nicely with standard functools.partial.
110
+ For details see: https://stackoverflow.com/questions/79749129/use-curve-fit-with-partial-using-named-parameters-instead-of-positional-para/79749198#79749198
111
+
112
+ """
113
+ f_sig = inspect.signature(f)
114
+ positional_params = (
115
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
116
+ inspect.Parameter.POSITIONAL_ONLY,
117
+ )
118
+ args = [
119
+ p.name for p in f_sig.parameters.values() if p.kind in positional_params
120
+ ]
121
+ new_args = [
122
+ inspect.Parameter(arg, inspect.Parameter.POSITIONAL_OR_KEYWORD)
123
+ for arg in args
124
+ if arg not in fixed_params
125
+ ]
126
+ new_sig = inspect.Signature(new_args)
127
+
128
+ def wrapper(*f_args: float, **f_kwargs: dict[str, float]) -> float:
129
+ bound_args = new_sig.bind(*f_args, **f_kwargs)
130
+ bound_args.apply_defaults()
131
+ return f(**bound_args.arguments, **fixed_params)
132
+
133
+ wrapper.__signature__ = new_sig # type: ignore[attr-defined]
134
+ wrapper.__name__ = f"kwpartial({f.__name__}, {fixed_params})"
135
+
136
+ return wrapper
137
+
138
+ def fit(self, p0: dict[str, float] | None = None) -> Self:
139
+ """
140
+ Fit the growth model using the parameters and data points provided to the model.
141
+
142
+ Parameters
143
+ ----------
144
+ p0 : dict[str, float], optional
145
+ Initial guesses for the missing parameters to be fitted.
146
+ May contain all or a subset of the missing parameters.
147
+ Any parameter not provided will be initialized with a starting guess of 1.0 (scipy's default).
148
+
149
+ Returns
150
+ -------
151
+ Self
152
+ The model instance with the fitted parameters set.
153
+
154
+ Raises
155
+ ------
156
+ ValueError
157
+ If there are not enough data points to fit the model.
158
+
159
+ """
160
+ # if all parameters of the model are already fixed, then we cannot fit anything
161
+ if len(self.provided_parameters) == len(self.model_parameters):
162
+ logger.info("All parameters are already fixed, cannot fit anything.")
163
+ return self
164
+
165
+ # The number of data points must be at least equal to the number of parameters to fit
166
+ if len(self.data_points) < len(self.missing_parameters):
167
+ raise ValueError(
168
+ f"Not enough data points to fit the model. Need at least {len(self.missing_parameters)}, got {len(self.data_points)}."
169
+ )
170
+
171
+ # Fit the model to the data points:
172
+ # build a partial function that includes the already fixed parameters
173
+ func = self._kwpartial(
174
+ self.function,
175
+ **self.model_dump(include=set(self.provided_parameters), exclude_none=True),
176
+ )
177
+
178
+ # p0 optionally allows to provide initial guesses for the parameters to fit
179
+ if p0 is None:
180
+ p0 = {}
181
+
182
+ # the dict needs to be transformed into a list with the parameters in the correct order
183
+ # if a parameter is missing from p0, we use 1 as a default initial guess (scipy's default)
184
+ p0_ = [p0.get(param, 1) for param in self.missing_parameters]
185
+
186
+ # fit the function to the data points
187
+ xdata, ydata = zip(*self.data_points)
188
+ popt, pcov = curve_fit(f=func, xdata=xdata, ydata=ydata, p0=p0_)
189
+
190
+ logger.debug(f"Fitted parameters: {popt}")
191
+ logger.debug(f"Covariance of the parameters: {pcov}")
192
+
193
+ # assign the fitted parameters to the model
194
+ for param, value in zip(self.missing_parameters, popt):
195
+ logger.debug(f"Setting parameter {param} to fitted value {value}")
196
+ setattr(self, param, value)
197
+
198
+ return self
199
+
200
+
201
+ class LinearGrowth(GrowthModel):
202
+ """Project with linear growth model."""
203
+
204
+ x0: Annotated[
205
+ float | None,
206
+ Field(
207
+ description="The reference x-value (e.g., starting year) for the linear function.",
208
+ ),
209
+ ] = None
210
+ m: Annotated[
211
+ float | None,
212
+ Field(description="Annual growth rate for the linear function."),
213
+ ] = None
214
+ A: Annotated[
215
+ float | None,
216
+ Field(description="Starting value for the linear function."),
217
+ ] = None
218
+
219
+ def function(
220
+ self, x: float | np.ndarray, x0: float, m: float, A: float
221
+ ) -> float | np.ndarray:
222
+ """
223
+ Linear function for the growth model.
224
+
225
+ f(x) = m * (x - x0) + a
226
+
227
+ Parameters
228
+ ----------
229
+ x : float | numpy Array
230
+ The input value(s) on which to evaluate the function, e.g. a year '2025'.
231
+ x0 : float
232
+ The reference x-value (e.g., starting year) for the linear function.
233
+ m : float, optional
234
+ The slope of the linear function.
235
+ A : float, optional
236
+ The constant offset of the linear function.
237
+
238
+ Returns
239
+ -------
240
+ float | numpy Array
241
+ The result(s) of the linear function evaluation at x.
242
+
243
+ """
244
+ return m * (x - x0) + A
245
+
246
+
247
+ class ExponentialGrowth(GrowthModel):
248
+ """Project with exponential growth model."""
249
+
250
+ x0: Annotated[
251
+ float | None,
252
+ Field(
253
+ description="The reference x-value (e.g., starting year) for the exponential function.",
254
+ ),
255
+ ] = None
256
+ A: Annotated[
257
+ float | None,
258
+ Field(description="Initial value for the exponential function."),
259
+ ] = None
260
+ m: Annotated[
261
+ float | None,
262
+ Field(description="The multiplier for the exponential function."),
263
+ ] = None
264
+ k: Annotated[
265
+ float | None,
266
+ Field(description="Growth rate for the exponential function."),
267
+ ] = None
268
+
269
+ def function(
270
+ self, x: float | np.ndarray, x0: float, A: float, m: float, k: float
271
+ ) -> float | np.ndarray:
272
+ """
273
+ Exponential function for the growth model.
274
+
275
+ f(x) = A + m * exp(k * (x - x0))
276
+
277
+ Parameters
278
+ ----------
279
+ x : float | numpy Array
280
+ The input value(s) on which to evaluate the function, e.g. a year '2025'.
281
+ x0 : float
282
+ The reference x-value (e.g., starting year) for the exponential function.
283
+ A : float
284
+ The lower horizontal asymptote of the exponential function.
285
+ m : float
286
+ The initial value of the exponential function.
287
+ k : float
288
+ The growth rate of the exponential function.
289
+
290
+ Returns
291
+ -------
292
+ float | numpy Array
293
+ The result(s) of the exponential function evaluation at x.
294
+
295
+ """
296
+ return A + m * np.exp(k * (x - x0))
297
+
298
+
299
+ class GeneralLogisticGrowth(GrowthModel):
300
+ """Project with a generalized logistic growth model."""
301
+
302
+ x0: Annotated[
303
+ float | None,
304
+ Field(
305
+ description="The x-value of the sigmoid's midpoint (inflection point/midpoint year).",
306
+ ),
307
+ ] = None
308
+ A: Annotated[
309
+ float | None,
310
+ Field(
311
+ description="The lower horizontal asymptote of the logistic function.",
312
+ ),
313
+ ] = None
314
+ K: Annotated[
315
+ float | None,
316
+ Field(
317
+ description="The upper horizontal asymptote of the logistic function for C=1.",
318
+ ),
319
+ ] = None
320
+ B: Annotated[
321
+ float | None,
322
+ Field(
323
+ description="The growth rate of the logistic function.",
324
+ ),
325
+ ] = None
326
+ Q: Annotated[
327
+ float | None,
328
+ Field(
329
+ description="Parameter related to the value of f(0).",
330
+ ),
331
+ ] = None
332
+ C: Annotated[
333
+ float | None,
334
+ Field(
335
+ description="Parameter related to the upper horizontal asymptote, often set to 1.",
336
+ ),
337
+ ] = None
338
+ nu: Annotated[
339
+ float | None,
340
+ Field(
341
+ description="Parameter affecting near which asymptote the maximum growth occurs.",
342
+ ),
343
+ ] = None
344
+
345
+ def function(
346
+ self,
347
+ x: float | np.ndarray,
348
+ x0: float,
349
+ A: float,
350
+ K: float,
351
+ B: float,
352
+ Q: float,
353
+ C: float,
354
+ nu: float,
355
+ ) -> float | np.ndarray:
356
+ """
357
+ Generalized logistic function for the growth model.
358
+
359
+ f(x) = A + (K - A) / (C + Q * exp(-B * (x - M)))^(1/nu)
360
+
361
+ Parameters
362
+ ----------
363
+ x : float | numpy Array
364
+ The input value(s) on which to evaluate the function, e.g. a year '2025'.
365
+ x0 : float
366
+ The x-value of the sigmoid's midpoint (inflection point).
367
+ A : float
368
+ The lower horizontal asymptote of the logistic function.
369
+ K : float
370
+ The upper horizontal asymptote of the logistic function for C=1.
371
+ If A=0 and C=1, then K is the carrying capacity.
372
+ B : float
373
+ The growth rate of the logistic function.
374
+ Q : float
375
+ Related to the value of f(0).
376
+ C : float
377
+ Parameter related to the upper horizontal asymptote, often set to 1.
378
+ nu : float
379
+ Parameter affecting near which asymptote the maximum growth occurs.
380
+
381
+ Returns
382
+ -------
383
+ float | numpy Array
384
+ The result(s) of the generalized logistic function evaluation at x.
385
+
386
+ """
387
+ return A + (K - A) / (C + Q * np.exp(-B * (x - x0))) ** (1.0 / nu)
388
+
389
+
390
+ class LogisticGrowth(GrowthModel):
391
+ """Project with a logistic growth model."""
392
+
393
+ x0: Annotated[
394
+ float | None,
395
+ Field(
396
+ description="The x-value of the sigmoid's midpoint (inflection point/midpoint year).",
397
+ ),
398
+ ] = None
399
+ A: Annotated[
400
+ float | None,
401
+ Field(
402
+ description="The lower horizontal asymptote of the logistic function.",
403
+ ),
404
+ ] = None
405
+ L: Annotated[
406
+ float | None,
407
+ Field(
408
+ description="Carrying capacity of the logistic function.",
409
+ ),
410
+ ] = None
411
+ k: Annotated[
412
+ float | None,
413
+ Field(
414
+ description="Growth rate of the logistic function.",
415
+ ),
416
+ ] = None
417
+
418
+ def function(
419
+ self, x: float | np.ndarray, x0: float, A: float, L: float, k: float
420
+ ) -> float | np.ndarray:
421
+ """
422
+ Logistic function for the growth model.
423
+
424
+ f(x) = A + L / (1 + exp(-k * (x - x0)))
425
+
426
+ Parameters
427
+ ----------
428
+ x : float | numpy Array
429
+ The input value(s) on which to evaluate the function, e.g. a year '2025'.
430
+ x0 : float
431
+ The x-value of the sigmoid's midpoint (inflection point).
432
+ A : float
433
+ The lower horizontal asymptote of the logistic function.
434
+ L : float
435
+ The carrying capacity of the logistic function.
436
+ k : float
437
+ The growth rate of the logistic function.
438
+
439
+ Returns
440
+ -------
441
+ float | numpy Array
442
+ The result(s) of the logistic function evaluation at x.
443
+
444
+ """
445
+ return A + L / (1 + np.exp(-k * (x - x0)))
446
+
447
+
448
+ class GompertzGrowth(GrowthModel):
449
+ """Project with a Gompertz growth model."""
450
+
451
+ A: Annotated[
452
+ float | None,
453
+ Field(
454
+ description="The upper asymptote (maximum value) of the Gompertz function.",
455
+ ),
456
+ ] = None
457
+ k: Annotated[
458
+ float | None,
459
+ Field(
460
+ description="The growth rate of the Gompertz function.",
461
+ ),
462
+ ] = None
463
+ x0: Annotated[
464
+ float | None,
465
+ Field(
466
+ description="The x-value of the inflection point (midpoint year) of the Gompertz function.",
467
+ ),
468
+ ] = None
469
+ b: Annotated[
470
+ float | None,
471
+ Field(
472
+ description="The displacement along the x-axis of the Gompertz function.",
473
+ ),
474
+ ] = None
475
+
476
+ def function(
477
+ self, x: float | np.ndarray, A: float, k: float, x0: float, b: float
478
+ ) -> float | np.ndarray:
479
+ """
480
+ Gompertz function for the growth model.
481
+
482
+ f(x) = A * exp(-b * exp(-k * (x - x0)))
483
+
484
+ Parameters
485
+ ----------
486
+ x : float | numpy Array
487
+ The input value(s) on which to evaluate the function, e.g. a year '2025'.
488
+ A : float
489
+ The upper asymptote (maximum value) of the Gompertz function.
490
+ k : float
491
+ The growth rate of the Gompertz function.
492
+ x0 : float
493
+ The x-value of the inflection point (midpoint year) of the Gompertz function.
494
+ b : float
495
+ The displacement along the x-axis of the Gompertz function.
496
+
497
+ Returns
498
+ -------
499
+ float | numpy Array
500
+ The result(s) of the Gompertz function evaluation at x.
501
+
502
+ """
503
+ return A * np.exp(-b * np.exp(-k * (x - x0)))
@@ -0,0 +1,191 @@
1
+ # SPDX-FileCopyrightText: technologydata contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Technology class for representing a technology with parameters and transformation methods."""
6
+
7
+ from typing import Annotated, Any, Self
8
+
9
+ import pydantic
10
+
11
+ from technologydata.parameter import Parameter
12
+
13
+
14
+ class Technology(pydantic.BaseModel):
15
+ """
16
+ Represent a technology with region, year, and a flexible set of parameters.
17
+
18
+ Attributes
19
+ ----------
20
+ name : str
21
+ Name of the technology.
22
+ detailed_technology : str
23
+ More detailed technology name.
24
+ case : str
25
+ Case or scenario identifier.
26
+ region : str
27
+ Region identifier.
28
+ year : int
29
+ Year of the data.
30
+ parameters : Dict[str, Parameter]
31
+ Dictionary of parameter names to Parameter objects.
32
+
33
+ """
34
+
35
+ name: Annotated[str, pydantic.Field(description="Name of the technology.")]
36
+ detailed_technology: Annotated[
37
+ str, pydantic.Field(description="Detailed technology name.")
38
+ ]
39
+ case: Annotated[str, pydantic.Field(description="Case or scenario identifier.")]
40
+ region: Annotated[str, pydantic.Field(description="Region identifier.")]
41
+ year: Annotated[int, pydantic.Field(description="Year of the data.")]
42
+ parameters: Annotated[
43
+ dict[str, Parameter],
44
+ pydantic.Field(default_factory=dict, description="Parameters."),
45
+ ]
46
+
47
+ def __getitem__(self, key: str) -> Parameter:
48
+ """
49
+ Access a parameter by name.
50
+
51
+ Parameters
52
+ ----------
53
+ key : str
54
+ Parameter name.
55
+
56
+ Returns
57
+ -------
58
+ Parameter
59
+ The requested parameter.
60
+
61
+ """
62
+ return self.parameters[key]
63
+
64
+ def __setitem__(self, key: str, value: Parameter) -> None:
65
+ """
66
+ Set a parameter by name.
67
+
68
+ Parameters
69
+ ----------
70
+ key : str
71
+ Parameter name.
72
+ value : Parameter
73
+ The parameter to set.
74
+
75
+ """
76
+ self.parameters[key] = value
77
+
78
+ def check_consistency(self) -> bool:
79
+ """
80
+ Check for consistency and completeness of parameters.
81
+
82
+ Returns
83
+ -------
84
+ bool
85
+ True if consistent, False otherwise.
86
+
87
+ """
88
+ # Example: check required parameters
89
+ required = ["specific_investment", "investment", "lifetime"]
90
+ missing = [p for p in required if p not in self.parameters]
91
+ return len(missing) == 0
92
+
93
+ def calculate_parameters(self, parameters: Any | None = None) -> Self:
94
+ """
95
+ Calculate missing or derived parameters.
96
+
97
+ Parameters
98
+ ----------
99
+ parameters : Optional[Any]
100
+ List of parameter names to calculate, or "<missing>" for all missing.
101
+
102
+ Returns
103
+ -------
104
+ Technology
105
+ A new Technology object with calculated parameters.
106
+
107
+ """
108
+ # Placeholder: implement calculation logic as needed
109
+ return self
110
+
111
+ def to_currency(
112
+ self,
113
+ target_currency: str,
114
+ overwrite_country: None | str = None,
115
+ source: str = "worldbank",
116
+ ) -> Self:
117
+ """
118
+ Adjust the currency of all parameters of the technology to the target currency.
119
+
120
+ The conversion includes inflation and exchange rates based on the object's region.
121
+ If a different country should be used for inflation adjustment, use `overwrite_country`.
122
+
123
+ Parameters
124
+ ----------
125
+ target_currency : str
126
+ The target currency (e.g., 'EUR_2020').
127
+ overwrite_country : str, optional
128
+ ISO 3166 alpha-3 country code to use for inflation adjustment instead of the object's region.
129
+ source: str, optional
130
+ The source of the inflation data, either "worldbank"/"wb" or "international_monetary_fund"/"imf".
131
+ Defaults to "worldbank".
132
+ Depending on the source, different years to adjust for inflation may be available.
133
+
134
+ Returns
135
+ -------
136
+ Technology
137
+ A new Technology object with all its parameters adjusted to the target currency.
138
+
139
+ """
140
+ country = self.region
141
+ if overwrite_country:
142
+ country = overwrite_country
143
+
144
+ # Copy the Technology object
145
+ new_tech: Self = self.model_copy(deep=True)
146
+
147
+ # Iterate over parameters and convert their currency
148
+ for name, param in new_tech.parameters.items():
149
+ new_tech.parameters[name] = param.to_currency(
150
+ target_currency=target_currency,
151
+ country=country,
152
+ source=source,
153
+ )
154
+
155
+ return new_tech
156
+
157
+ def adjust_region(self, target_region: str) -> Self:
158
+ """
159
+ Adjust technology parameters to match a different region.
160
+
161
+ Parameters
162
+ ----------
163
+ target_region : str
164
+ The target region.
165
+
166
+ Returns
167
+ -------
168
+ Technology
169
+ A new Technology object with adjusted region.
170
+
171
+ """
172
+ # Placeholder: implement region adjustment logic
173
+ return self
174
+
175
+ def adjust_scale(self, scaling_factor: float) -> Self:
176
+ """
177
+ Scale parameter values by a scaling factor.
178
+
179
+ Parameters
180
+ ----------
181
+ scaling_factor : float
182
+ The scaling factor to apply.
183
+
184
+ Returns
185
+ -------
186
+ Technology
187
+ A new Technology object with scaled parameters.
188
+
189
+ """
190
+ # Placeholder: implement scaling logic
191
+ return self