|
43 | 43 | PV_PEAK_OPTIONS_KW = [0.0, 1.0, 2.0, 4.0, 6.0, 10.0] |
44 | 44 |
|
45 | 45 | RATE_TYPES = ["single", "dual", "triple", "halfhourly"] |
| 46 | +IMPORT_RATE_TYPES = RATE_TYPES + ["negative_halfhourly"] |
46 | 47 |
|
47 | 48 | # --------------------------------------------------------------------------- |
48 | 49 | # Daily profile generators (return list[float] with exactly 1440 elements) |
49 | 50 | # --------------------------------------------------------------------------- |
50 | 51 |
|
51 | 52 |
|
| 53 | +def _smooth_rate_profile(control_points, allow_negative=False): |
| 54 | + """Cosine-interpolate between control points to produce a 1440-minute rate profile. |
| 55 | +
|
| 56 | + Adjacent control points are connected with a smooth S-curve (raised cosine), so the |
| 57 | + profile ramps gradually between each peak/trough rather than jumping abruptly. |
| 58 | +
|
| 59 | + Args: |
| 60 | + control_points: list of (minute, rate) pairs covering at least minute 0 and 1439 |
| 61 | + allow_negative: if False, clamp all output values to >= 0 |
| 62 | +
|
| 63 | + Returns: |
| 64 | + list[float] of 1440 values |
| 65 | + """ |
| 66 | + pts = sorted(control_points, key=lambda x: x[0]) |
| 67 | + profile = [] |
| 68 | + idx = 0 |
| 69 | + n = len(pts) |
| 70 | + for m in range(MINUTES_PER_DAY): |
| 71 | + while idx + 1 < n and pts[idx + 1][0] <= m: |
| 72 | + idx += 1 |
| 73 | + t0, v0 = pts[idx] |
| 74 | + if idx + 1 >= n: |
| 75 | + val = v0 |
| 76 | + else: |
| 77 | + t1, v1 = pts[idx + 1] |
| 78 | + span = t1 - t0 |
| 79 | + mu = (m - t0) / span if span > 0 else 1.0 |
| 80 | + val = v0 + (v1 - v0) * (1.0 - math.cos(mu * math.pi)) / 2.0 |
| 81 | + if not allow_negative: |
| 82 | + val = max(0.0, val) |
| 83 | + profile.append(val) |
| 84 | + return profile |
| 85 | + |
| 86 | + |
52 | 87 | def generate_rates_day(rate_type, params, seed): |
53 | 88 | """Generate a single-day (1440-minute) per-minute rate profile in p/kWh. |
54 | 89 |
|
55 | 90 | Args: |
56 | | - rate_type: One of "single", "dual", "triple", "halfhourly" |
| 91 | + rate_type: One of "single", "dual", "triple", "halfhourly", "negative_halfhourly" |
57 | 92 | params: dict of rate parameters (see per-type docs below) |
58 | | - seed: integer seed for halfhourly random generation |
| 93 | + seed: integer seed for halfhourly/negative_halfhourly random generation |
59 | 94 |
|
60 | 95 | Returns: |
61 | 96 | list[float] of 1440 values |
@@ -95,15 +130,21 @@ def generate_rates_day(rate_type, params, seed): |
95 | 130 | else: |
96 | 131 | profile[m] = shoulder_rate |
97 | 132 |
|
98 | | - elif rate_type == "halfhourly": |
99 | | - # 48 half-hourly slots, randomly priced, each slot is 30 minutes |
| 133 | + elif rate_type in ("halfhourly", "negative_halfhourly"): |
| 134 | + # Smooth profile: cosine-interpolate between randomly placed peaks and troughs |
| 135 | + allow_negative = rate_type == "negative_halfhourly" |
100 | 136 | rng = random.Random(seed) |
101 | | - base = float(params.get("base_rate", 10.0)) |
102 | | - spread = float(params.get("spread", 20.0)) |
103 | | - slots = [max(0.0, base + rng.uniform(-spread / 2, spread / 2)) for _ in range(48)] |
104 | | - for m in range(MINUTES_PER_DAY): |
105 | | - slot = m // 30 |
106 | | - profile[m] = slots[slot] |
| 137 | + base = float(params.get("base_rate", 15.0)) |
| 138 | + low = float(params.get("low_rate", 5.0)) |
| 139 | + high = float(params.get("high_rate", 30.0)) |
| 140 | + num_highs = int(params.get("num_highs", 2)) |
| 141 | + num_lows = int(params.get("num_lows", 2)) |
| 142 | + control_points = [(0, base), (MINUTES_PER_DAY - 1, base)] |
| 143 | + for _ in range(num_highs): |
| 144 | + control_points.append((rng.randint(0, MINUTES_PER_DAY - 1), high)) |
| 145 | + for _ in range(num_lows): |
| 146 | + control_points.append((rng.randint(0, MINUTES_PER_DAY - 1), low)) |
| 147 | + profile = _smooth_rate_profile(control_points, allow_negative) |
107 | 148 |
|
108 | 149 | return profile |
109 | 150 |
|
@@ -228,7 +269,7 @@ def generate_random_scenario(scenario_id, seed): |
228 | 269 | sunset_minute = rng.randint(PV_SUNSET_MINUTE_MIN, PV_SUNSET_MINUTE_MAX) |
229 | 270 |
|
230 | 271 | # --- Import rate --- |
231 | | - import_rate_type = rng.choice(RATE_TYPES) |
| 272 | + import_rate_type = rng.choice(IMPORT_RATE_TYPES) |
232 | 273 | import_rate_params = _sample_rate_params(rng, import_rate_type, cheap=True) |
233 | 274 | import_rate_seed = rng.randint(0, 2**31) |
234 | 275 |
|
@@ -1039,7 +1080,19 @@ def _sample_rate_params(rng, rate_type, cheap): |
1039 | 1080 | "peak_end_hhmm": "{}:00".format(peak_end_hour), |
1040 | 1081 | } |
1041 | 1082 |
|
1042 | | - else: # halfhourly |
1043 | | - base = round(rng.uniform(10.0, 25.0), 2) |
1044 | | - spread = round(rng.uniform(10.0, 30.0), 2) |
1045 | | - return {"base_rate": base, "spread": spread} |
| 1083 | + elif rate_type == "halfhourly": |
| 1084 | + if cheap: |
| 1085 | + base = round(rng.uniform(15.0, 25.0), 2) |
| 1086 | + low = round(rng.uniform(3.0, 12.0), 2) |
| 1087 | + high = round(rng.uniform(30.0, 50.0), 2) |
| 1088 | + else: |
| 1089 | + base = round(rng.uniform(8.0, 18.0), 2) |
| 1090 | + low = round(rng.uniform(2.0, 8.0), 2) |
| 1091 | + high = round(rng.uniform(18.0, 30.0), 2) |
| 1092 | + return {"base_rate": base, "low_rate": low, "high_rate": high, "num_highs": rng.randint(1, 3), "num_lows": rng.randint(1, 3)} |
| 1093 | + |
| 1094 | + else: # negative_halfhourly — troughs go negative to test plunge-pricing scenarios |
| 1095 | + base = round(rng.uniform(5.0, 15.0), 2) |
| 1096 | + low = round(rng.uniform(-15.0, 0.0), 2) |
| 1097 | + high = round(rng.uniform(25.0, 50.0), 2) |
| 1098 | + return {"base_rate": base, "low_rate": low, "high_rate": high, "num_highs": rng.randint(1, 3), "num_lows": rng.randint(1, 3)} |
0 commit comments